Merge branch 'refactor/simplify-render'

Simplify render pipeline, sandbox context, and db helpers; fix route
precedence so the most recently saved route wins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-08-02 22:36:07 -04:00
commit fb3d904f5b
2 changed files with 175 additions and 207 deletions

59
db.js
View file

@ -167,51 +167,28 @@ function getRoute(routeId) {
.get(routeId); .get(routeId);
} }
function updateRoute(route) { // Update `fields` of a row by id from a plain object. Returns the run info.
const fields = [ function update(table, fields, obj) {
"verb",
"path",
"structure_id",
"handler",
"updated_at",
"error",
];
const values = fields.map((field) => route[field]);
const placeholders = fields.map((field) => `${field} = ?`).join(", "); 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 = ?`; function updateRoute(route) {
values.push(route.id); // Add routeId to the end for the WHERE clause update("routes", ["verb", "path", "structure_id", "handler", "updated_at", "error"], route);
db.prepare(sql).run(...values);
} }
function updateDb(appDb) { function updateDb(appDb) {
const fields = ["name", "library"]; update("dbs", ["name", "library"], appDb);
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);
} }
function updateStruct(struct) { function updateStruct(struct) {
const fields = ["name", "route_prefix", "head_injection"]; update("structures", ["name", "route_prefix", "head_injection"], struct);
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);
} }
function updateTemplate(template) { function updateTemplate(template) {
const fields = ["content", "name", "test_object"]; update("templates", ["content", "name", "test_object"], template);
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);
} }
function getTemplates(structureId) { function getTemplates(structureId) {
@ -490,9 +467,7 @@ function createScaffoldPage(routeId, content=null) {
const route = getRoute(routeId) const route = getRoute(routeId)
const endpoint = buildScaffoldUrl(route.path, route.url_params, route.query_params); const endpoint = buildScaffoldUrl(route.path, route.url_params, route.query_params);
content = getDefaultScaffoldContentByVerb(endpoint, route.verb); content = getDefaultScaffoldContentByVerb(endpoint, route.verb);
console.log("gabga", content, route, endpoint)
} }
console.log(content, "content")
const stmt = db.prepare( const stmt = db.prepare(
"INSERT INTO scaffold_pages (route_id, content) VALUES (?, ?)" "INSERT INTO scaffold_pages (route_id, content) VALUES (?, ?)"
); );
@ -508,17 +483,7 @@ function getLatestScaffoldPage(routeId) {
} }
function updateScaffoldPage(scaffoldPage) { function updateScaffoldPage(scaffoldPage) {
const fields = ["content"]; return update("scaffold_pages", ["content"], scaffoldPage).changes > 0;
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
} }
function generatePostForm(endpoint) { function generatePostForm(endpoint) {

319
index.js
View file

@ -33,6 +33,15 @@ function inspectArgs(args) {
return args.map((v) => util.inspect(v, INSPECT_OPTS)).join(" "); 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.urlencoded({ extended: true }));
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use(express.static("public")); app.use(express.static("public"));
@ -83,53 +92,55 @@ async function saveFile(structureId, req, uploadedFile, asset=false) {
return file return file
} }
function bootstrapContext(structureId, routeId, initContext) { // A console whose log() mirrors to stdout and to the structure's logs table.
const allDbInstances = {}; function makeConsole(structureId, routeId) {
function getDb(alias) { return {
return allDbInstances[alias];
}
// 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;
const libs = { eta, db: getDb, push: webPush, files: { saveFile: (...args) => saveFile(structureId, ...args) } };
let dbs = model.getDbsForStructure(structureId);
let context = vm.createContext({
...initContext,
require: function (str) {
return libs[str];
},
module: { exports: null },
console: {
log: function (...content) { log: function (...content) {
content.forEach((c) => console.log(c)); content.forEach((c) => console.log(c));
model.createLog(structureId, routeId, inspectArgs(content)); 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) },
};
}
function bootstrapContext(structureId, routeId, initContext) {
const structure = model.getStructure(structureId);
const console = makeConsole(structureId, routeId);
const libs = makeLibs(structureId, console);
return vm.createContext({
...initContext,
require: (name) => libs[name],
module: { exports: null },
console,
vapidPublicKey: vapidPublicKey, vapidPublicKey: vapidPublicKey,
fetch: fetch, fetch: fetch,
clearTimeout: clearTimeout, clearTimeout: clearTimeout,
setTimeout: setTimeout, setTimeout: setTimeout,
route: function (url) { route: makeRoute(structure),
return withPrefix(__urlPrefix, url);
},
}); });
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) { function withPrefix(prefix, url) {
@ -160,28 +171,19 @@ function bootstrapWebsocketHandler(route) {
cb(...args) cb(...args)
} }
catch (e) { catch (e) {
const error = e.stack ? `${e}\n\n${e.stack}` : `${e}`; logError(route.structure_id, route.id, e);
model.createLog(route.structure_id, route.id, error, true);
} }
}) })
} }
ws.render = (template, context) => { ws.render = (template, context = {}) => {
context = context || {};
const eta = model.getTemplater(route.structure_id);
const structure = model.getStructure(route.structure_id); const structure = model.getStructure(route.structure_id);
context.route = function (url) { context.route = makeRoute(structure);
return withPrefix(structure.route_prefix, url);
};
ws.send( ws.send(
bootstrapTemplateWithHTMXetc( decorate(renderTemplate(route.structure_id, template, context), {
eta.render(template, context), headInjection: structure.head_injection,
`/workshop/${route.structure_id}/route/${route.id}`, source: sourceFor(route, req),
`/workshop/${route.structure_id}/clone_modal/`, fragment: true,
route.verb == "GET" ? embedHTML(req.originalUrl) : null, }),
structure.head_injection,
htmxRequest = true,
),
); );
}; };
@ -195,14 +197,12 @@ function bootstrapWebsocketHandler(route) {
try { try {
return handler(ws, req) return handler(ws, req)
} catch (e) { } catch (e) {
const error = e.stack ? `${e}\n\n${e.stack}` : `${e}`; logError(route.structure_id, route.id, e);
model.createLog(route.structure_id, route.id, error, true);
} }
} }
model.updateRoute({ ...route, error: null }); model.updateRoute({ ...route, error: null });
} catch (e) { } catch (e) {
const error = e.stack ? `${e}\n\n${e.stack}` : `${e}`; logError(route.structure_id, route.id, e);
model.createLog(route.structure_id, route.id, error, true);
model.updateRoute({ ...route, error: e.stack }); model.updateRoute({ ...route, error: e.stack });
} }
} }
@ -297,30 +297,10 @@ app.all("/logout", async (req, res) => {
// | _ || || | | || _ | _____| || _ || || | // | _ || || | | || _ | _____| || _ || || |
// |__| |__||_______||___| |_||___| |_||_______||__| |__||_______||___| // |__| |__||_______||___| |_||___| |_||_______||__| |__||_______||___|
// //
function bootstrapTemplateWithHTMXetc( // The chrome injected into the <head> of every rendered page: the client-side
htmlString, // stack (htmx/hyperscript/tailwind) plus the in-page editor overlay.
blissRoute, function headChrome(headInjection) {
blissClone, return `
blissCopy,
headInjection,
htmxRequest = false,
) {
if (
htmlString.toLowerCase().startsWith("<html>") ||
htmlString.toLowerCase().startsWith("<!doctype") ||
!htmxRequest
) {
const $ = cheerio.load(htmlString);
let head = $("head");
if (head.length === 0) {
$("html").prepend("<head></head>");
head = $("head");
}
head.attr("id", "head");
head.append(`
<script src="/js/hyperscript.js"></script> <script src="/js/hyperscript.js"></script>
<script src="/js/tailwind.js"></script> <script src="/js/tailwind.js"></script>
<script src="/js/htmx.js"></script> <script src="/js/htmx.js"></script>
@ -335,48 +315,96 @@ function bootstrapTemplateWithHTMXetc(
</script> </script>
<style> body { margin: 0; }</style> <style> body { margin: 0; }</style>
${headInjection || ""} ${headInjection || ""}
`); `;
if (blissRoute) {
$("body").attr("data-bliss-route", blissRoute);
$("body").attr("data-bliss-clone", blissClone);
if (blissCopy) $("body").attr("data-bliss-copy", blissCopy);
} }
htmlString = $.html(); // A `source` is the provenance of a rendered fragment: which structure/route
} else if (htmxRequest && blissRoute) { // produced it, and (for GET routes) the snippet that re-embeds it. It becomes
const $ = cheerio.load(htmlString, null, false); // 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;
}
// Full HTML document: inject the head chrome and stamp provenance on <body>.
function decoratePage(html, { headInjection, source } = {}) {
const $ = cheerio.load(html);
let head = $("head");
if (head.length === 0) {
$("html").prepend("<head></head>");
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
// <head> 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 = []; const targets = [];
for (const child of $.root().children()) {
// 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()) {
if (child.attribs && child.attribs["hx-swap-oob"]) { if (child.attribs && child.attribs["hx-swap-oob"]) {
for (let childchild of child.children) { for (const grandchild of child.children) {
if (childchild.attribs) { if (grandchild.attribs) targets.push(grandchild);
targets.push(childchild);
}
} }
} else { } else {
targets.push(child); targets.push(child);
} }
} }
for (const target of targets) Object.assign(target.attribs, attrs);
// Directly update attributes without wrapping in Cheerio }
for (let target of targets) { return (
target.attribs["data-bliss-route"] = blissRoute; $.html() +
target.attribs["data-bliss-clone"] = blissClone; `<head id="head" hx-oob-swap="beforeend">${headInjection || ""}</head>`
if (blissCopy) target.attribs["data-bliss-copy"] = blissCopy; );
} }
htmlString = $.html(); // 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
if (htmxRequest) { // carrying provenance becomes a decorated fragment; anything else passes through.
htmlString += `<head id="head" hx-oob-swap="beforeend">${headInjection}</head>`; function decorate(html, { headInjection, source, fragment } = {}) {
const lower = html.toLowerCase();
const isFullDoc =
lower.startsWith("<html>") || lower.startsWith("<!doctype");
if (isFullDoc || !fragment) {
return decoratePage(html, { headInjection, source });
} }
if (source) return decorateFragment(html, { headInjection, source });
return html;
} }
return htmlString; // One prefix-bound route() helper per structure, shared by every render site.
function makeRoute(structure) {
return (url) => 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) => { app.post("/workshop", (req, res) => {
@ -391,7 +419,7 @@ app.get("/workshop", (req, res) => {
}); });
function renderWorkshop(res, template, context) { function renderWorkshop(res, template, context) {
res.send(bootstrapTemplateWithHTMXetc(eta.render(template, context))); res.send(decorate(eta.render(template, context)));
} }
function smartRedirect(req, res, redirectUrl) { 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) => { app.get("/workshop/:structure_id/template/:template_id/preview", (req, res) => {
const template = model.getTemplate(req.params.template_id); const template = model.getTemplate(req.params.template_id);
const struct = model.getStructure(req.params.structure_id); const struct = model.getStructure(req.params.structure_id);
const eta = model.getTemplater(req.params.structure_id);
const context = vm.createContext({ it: null }); const context = vm.createContext({ it: null });
vm.runInContext(template.test_object, context); vm.runInContext(template.test_object, context);
context.it.route = function (url) { context.it.route = makeRoute(struct);
return withPrefix(struct.route_prefix, url);
};
return res.send( return res.send(
bootstrapTemplateWithHTMXetc( decorate(renderTemplate(req.params.structure_id, template.name, context.it), {
eta.render(template.name, context.it), headInjection: struct.head_injection,
null, }),
null,
null,
struct.head_injection,
false,
),
); );
}); });
@ -647,21 +667,12 @@ app.get("/workshop/:structure_id/route/:route_id/preview", (req, res) => {
const route = model.getRoute(req.params.route_id); const route = model.getRoute(req.params.route_id);
const struct = model.getStructure(req.params.structure_id); const struct = model.getStructure(req.params.structure_id);
const it = { const it = { route: makeRoute(struct) };
route: function (url) {
return withPrefix(struct.route_prefix, url);
}
}
return res.send( return res.send(
bootstrapTemplateWithHTMXetc( decorate(eta.renderString(route.scaffold_page_content, it), {
eta.renderString(route.scaffold_page_content, it), headInjection: struct.head_injection,
null, }),
null,
null,
struct.head_injection,
false,
),
); );
}); });
@ -792,21 +803,21 @@ app.all("*", async (req, res) => {
try { try {
let routeMatch = null; 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]) { for (let { matcher, id } of routes[verb]) {
let matchFromRoute = matcher(req_path); let matchFromRoute = matcher(req_path);
if (matchFromRoute) { if (matchFromRoute) {
routeMatch = { params: matchFromRoute.params, id: id }; routeMatch = { params: matchFromRoute.params, id: id };
break;
} }
} }
if (routeMatch) { if (routeMatch) {
req.params = routeMatch.params; req.params = routeMatch.params;
const route = db const route = model.getRoute(routeMatch.id);
.prepare("SELECT * FROM routes WHERE id = ?")
.get(routeMatch.id);
const structure = model.getStructure(route.structure_id); const structure = model.getStructure(route.structure_id);
const __urlPrefix = structure.route_prefix;
try { try {
// todo: only add req res to contexst when running the handler() // todo: only add req res to contexst when running the handler()
@ -817,21 +828,14 @@ app.all("*", async (req, res) => {
eta, eta,
}); });
res.render = (template, context) => { res.render = (template, context = {}) => {
context = context || {}; context.route = makeRoute(structure);
context.route = function (url) {
return withPrefix(__urlPrefix, url);
};
const eta = model.getTemplater(route.structure_id);
res.send( res.send(
bootstrapTemplateWithHTMXetc( decorate(renderTemplate(route.structure_id, template, context), {
eta.render(template, context), headInjection: structure.head_injection,
`/workshop/${route.structure_id}/route/${route.id}`, source: sourceFor(route, req),
`/workshop/${route.structure_id}/clone_modal/`, fragment: req.headers["hx-request"],
verb == "GET" ? embedHTML(req.originalUrl) : null, }),
structure.head_injection,
req.headers["hx-request"],
),
); );
}; };
const handlerScript = new vm.Script(route.handler, { filename: `handler_${route.id}.js` }); 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); await handlerScript.runInContext(context);
const result = await executionScript.runInContext(context); const result = await executionScript.runInContext(context);
} catch (e) { } catch (e) {
const error = e.stack ? `${e}\n\n${e.stack}` : `${e}`; logError(structure.id, route.id, e);
model.createLog(structure.id, route.id, error, true); return res.status(500).send(formatError(e));
return res.status(500).send(error);
} }
} else { } else {
res.status(404).json({ success: false, message: "Path not found" }); res.status(404).json({ success: false, message: "Path not found" });