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:
commit
fb3d904f5b
2 changed files with 175 additions and 207 deletions
59
db.js
59
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) {
|
||||
|
|
|
|||
319
index.js
319
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];
|
||||
}
|
||||
|
||||
// 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: {
|
||||
// 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) },
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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("<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(`
|
||||
// The chrome injected into the <head> of every rendered page: the client-side
|
||||
// stack (htmx/hyperscript/tailwind) plus the in-page editor overlay.
|
||||
function headChrome(headInjection) {
|
||||
return `
|
||||
<script src="/js/hyperscript.js"></script>
|
||||
<script src="/js/tailwind.js"></script>
|
||||
<script src="/js/htmx.js"></script>
|
||||
|
|
@ -335,48 +315,96 @@ function bootstrapTemplateWithHTMXetc(
|
|||
</script>
|
||||
<style> body { margin: 0; }</style>
|
||||
${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();
|
||||
} else if (htmxRequest && blissRoute) {
|
||||
const $ = cheerio.load(htmlString, null, false);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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 = [];
|
||||
|
||||
// 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;
|
||||
for (const target of targets) Object.assign(target.attribs, attrs);
|
||||
}
|
||||
return (
|
||||
$.html() +
|
||||
`<head id="head" hx-oob-swap="beforeend">${headInjection || ""}</head>`
|
||||
);
|
||||
}
|
||||
|
||||
htmlString = $.html();
|
||||
|
||||
if (htmxRequest) {
|
||||
htmlString += `<head id="head" hx-oob-swap="beforeend">${headInjection}</head>`;
|
||||
// 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("<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) => {
|
||||
|
|
@ -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" });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue