websocket support and route fixes

This commit is contained in:
HRG @ SCExC 2024-06-08 18:29:31 -04:00
parent 897dd38151
commit c40bdcbbed
6 changed files with 218 additions and 84 deletions

237
index.js
View file

@ -8,9 +8,11 @@ const SQLiteStore = require("better-sqlite3-session-store")(session);
const betterSqlite3 = require("better-sqlite3"); const betterSqlite3 = require("better-sqlite3");
const { Eta } = require("eta"); const { Eta } = require("eta");
const { match } = require("path-to-regexp"); const { match } = require("path-to-regexp");
const { LRUCache } = require("lru-cache");
const bcrypt = require("bcrypt"); const bcrypt = require("bcrypt");
const cheerio = require("cheerio"); const cheerio = require("cheerio");
const app = express(); const app = express();
const _expressWs = require("express-ws")(app);
const bodyParser = require("body-parser"); const bodyParser = require("body-parser");
const PORT = 3000; const PORT = 3000;
@ -27,6 +29,39 @@ const db = betterSqlite3("./dbs/0.sqlite");
db.pragma("journal_mode = WAL"); db.pragma("journal_mode = WAL");
const dbCache = new LRUCache({ max: 25 });
function getDbInstance(dbId) {
let dbInstance = dbCache.get(dbId);
if (!dbInstance) {
dbInstance = betterSqlite3(`dbs/${dbId}.sqlite`);
dbInstance.pragma("journal_mode = WAL");
dbCache.set(dbId, dbInstance);
}
return dbInstance;
}
const templateCache = new LRUCache({ max: 100 });
function getTemplater(structId) {
let etaInstance = templateCache.get(structId);
if (!etaInstance) {
etaInstance = new Eta({});
etaInstance.resolvePath = function (path, _) {
return path;
};
etaInstance.readFile = function (templateAlias) {
return getTemplateContentByName(db, structId, templateAlias).content;
};
templateCache.set(structId, etaInstance);
}
return etaInstance;
}
function getAllRoutes(db) { function getAllRoutes(db) {
return db return db
.prepare( .prepare(
@ -78,6 +113,61 @@ function applyMigrations() {
}); });
} }
function bootstrapContext(db, structureId, 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 = getTemplater(structureId);
const libs = { eta, db: getDb };
let dbs = getDbsForStructure(db, structureId);
let context = vm.createContext({
...initContext,
require: function (str) {
return libs[str];
},
module: { exports: null },
});
for (let appDb of dbs) {
let dbInstance = 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 routeWithPrefix(route) {
const prefix = route.route_prefix;
return prefix ? path.join(prefix, route.path) : route.path;
}
function bootstrapWebsocketHandler(route) {
// todo only expose app when running the handler, should not be available to the handler itself
// need to move to runscript or whatever
try {
const context = bootstrapContext(db, route.structure_id, { app });
let result = vm.runInContext(
route.handler + `\n\napp.ws("${routeWithPrefix(route)}", handler)`,
context
);
updateRoute(db, { ...route, error: null });
return result;
} catch (e) {
updateRoute(db, { ...route, error: e.stack });
}
}
function buildRoutes() { function buildRoutes() {
let newRoutes = { let newRoutes = {
GET: [], GET: [],
@ -87,13 +177,16 @@ function buildRoutes() {
}; };
for (let route of getAllRoutes(db)) { for (let route of getAllRoutes(db)) {
const prefix = route.route_prefix; const p = routeWithPrefix(route);
const p = prefix ? path.join(route.route_prefix, route.path) : route.path; if (route.verb == "WS") {
newRoutes[route.verb].push({ bootstrapWebsocketHandler(route);
matcher: match(p, { decode: decodeURIComponent }), } else {
id: route.id, newRoutes[route.verb].push({
path: route.path, matcher: match(p, { decode: decodeURIComponent }),
}); id: route.id,
path: route.path,
});
}
} }
routes = newRoutes; routes = newRoutes;
} }
@ -192,6 +285,7 @@ function createStructure(db, name, userId) {
} }
function createRoute(db, verb, path, structureId, handler) { function createRoute(db, verb, path, structureId, handler) {
path = encodeURI(path)
// add default handler here // add default handler here
const stmt = db.prepare( const stmt = db.prepare(
"INSERT INTO routes (verb, path, structure_id, handler) VALUES (?, ?, ?, ?)" "INSERT INTO routes (verb, path, structure_id, handler) VALUES (?, ?, ?, ?)"
@ -211,7 +305,14 @@ function getRoute(db, routeId) {
} }
function updateRoute(db, route) { function updateRoute(db, route) {
const fields = ["verb", "path", "structure_id", "handler", "updated_at"]; const fields = [
"verb",
"path",
"structure_id",
"handler",
"updated_at",
"error",
];
const values = fields.map((field) => route[field]); const values = fields.map((field) => route[field]);
const placeholders = fields.map((field) => `${field} = ?`).join(", "); const placeholders = fields.map((field) => `${field} = ?`).join(", ");
@ -232,7 +333,6 @@ function updateDb(db, appDb) {
function updateStruct(db, struct) { function updateStruct(db, struct) {
const fields = ["name", "route_prefix", "head_injection"]; const fields = ["name", "route_prefix", "head_injection"];
console.log(struct, fields);
const values = fields.map((field) => struct[field]); const values = fields.map((field) => struct[field]);
const placeholders = fields.map((field) => `${field} = ?`).join(", "); const placeholders = fields.map((field) => `${field} = ?`).join(", ");
@ -358,8 +458,6 @@ function getFilesForStruct(db, structureId) {
.prepare("SELECT * FROM files WHERE structure_id = ? ORDER BY id DESC") .prepare("SELECT * FROM files WHERE structure_id = ? ORDER BY id DESC")
.all(structureId); .all(structureId);
console.log(db.prepare("PRAGMA table_info(files)").all());
return test; return test;
} }
@ -375,39 +473,6 @@ function createFile(db, structure_id, name, filePath, mime_type, mime_subtype) {
.run(structure_id, name, filePath, mime_type, mime_subtype).lastInsertRowid; .run(structure_id, name, filePath, mime_type, mime_subtype).lastInsertRowid;
} }
const { LRUCache } = require("lru-cache");
const templateCache = new LRUCache({ max: 100 });
function getTemplater(structId) {
let etaInstance = templateCache.get(structId);
if (!etaInstance) {
etaInstance = new Eta({});
etaInstance.resolvePath = function (path, _) {
return path;
};
etaInstance.readFile = function (templateAlias) {
return getTemplateContentByName(db, structId, templateAlias).content;
};
templateCache.set(structId, etaInstance);
}
return etaInstance;
}
const dbCache = new LRUCache({ max: 25 });
function getDbInstance(dbId) {
let dbInstance = dbCache.get(dbId);
if (!dbInstance) {
dbInstance = betterSqlite3(`dbs/${dbId}.sqlite`);
dbInstance.pragma("journal_mode = WAL");
dbCache.set(dbId, dbInstance);
}
return dbInstance;
}
function cloneStructure( function cloneStructure(
structId, structId,
@ -436,8 +501,6 @@ function cloneStructure(
const toClone = new Set(cloneDbs); const toClone = new Set(cloneDbs);
const toAlias = new Set(); const toAlias = new Set();
console.log(toClone, dbIds);
for (let { db_id } of dbIds) { for (let { db_id } of dbIds) {
if (!toClone.has(db_id.toString())) { if (!toClone.has(db_id.toString())) {
toAlias.add(db_id.toString()); toAlias.add(db_id.toString());
@ -504,10 +567,13 @@ function bootstrapTemplateWithHTMXetc(
blissClone, blissClone,
blissCopy, blissCopy,
headInjection, headInjection,
htmxRequest=false htmxRequest = false
) { ) {
if (htmlString.toLowerCase().startsWith("<html>") || htmlString.toLowerCase().startsWith("<!doctype") || !htmxRequest) { if (
console.log("grow") htmlString.toLowerCase().startsWith("<html>") ||
htmlString.toLowerCase().startsWith("<!doctype") ||
!htmxRequest
) {
const $ = cheerio.load(htmlString); const $ = cheerio.load(htmlString);
let head = $("head"); let head = $("head");
@ -516,12 +582,13 @@ function bootstrapTemplateWithHTMXetc(
head = $("head"); head = $("head");
} }
head.attr("id", "head") head.attr("id", "head");
head.append(` 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>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/ws.js"></script>
<script src="/js/bliss_inspector.js"></script> <script src="/js/bliss_inspector.js"></script>
${headInjection || ""} ${headInjection || ""}
`); `);
@ -533,13 +600,12 @@ function bootstrapTemplateWithHTMXetc(
} }
htmlString = $.html(); htmlString = $.html();
console.log(htmlString)
} else if (htmxRequest && blissRoute) { } else if (htmxRequest && blissRoute) {
const $ = cheerio.load(htmlString, null, false); const $ = cheerio.load(htmlString, null, false);
$.root().children().attr("data-bliss-route", blissRoute); $.root().children().attr("data-bliss-route", blissRoute);
$.root().children().attr("data-bliss-clone", blissClone); $.root().children().attr("data-bliss-clone", blissClone);
if (blissCopy) $.root().children().attr("data-bliss-copy", blissCopy); if (blissCopy) $.root().children().attr("data-bliss-copy", blissCopy);
htmlString = $.html() htmlString = $.html();
if (htmxRequest) { if (htmxRequest) {
htmlString += `<div id="head" hx-oob-swap="before_end">${headInjection}</div>`; htmlString += `<div id="head" hx-oob-swap="before_end">${headInjection}</div>`;
@ -642,14 +708,27 @@ app.get("/workshop/:structure_id/db/:db_id", (req, res) => {
}); });
app.post("/workshop/:structure_id/route", (req, res) => { app.post("/workshop/:structure_id/route", (req, res) => {
let path = req.body.path; let p = req.body.path;
let dummyHandler =
"// put your handler code here\n\nfunction handler(req, res) {\n res.send('henlo world') \n}";
if (req.body.verb == "WS") {
dummyHandler = `// put your websocket handler code here\n\nfunction handler(ws, req) {\n ws.on('message', function(msg) {\n ws.send(msg);\n })\n}`;
}
if (p[0] == "/") {
p = p.substring(1);
}
if (p[p.length - 1] == "/") {
p = p.substring(0, p.length - 1);
}
const route = createRoute( const route = createRoute(
db, db,
req.body.verb, req.body.verb,
path[0] == "/" ? path : "/" + path, p[0] == "/" ? p.substring(1) : "/" + p,
req.params.structure_id, req.params.structure_id,
"// put your handler code here\n\nfunction handler(req, res) {\n res.send('henlo world') \n}" dummyHandler
); );
// todo optimize by only adding new, don't just rebuild all // todo optimize by only adding new, don't just rebuild all
buildRoutes(); buildRoutes();
@ -782,10 +861,18 @@ app.get("/workshop/:structure_id/template/:template_id/preview", (req, res) => {
app.get("/workshop/:structure_id/route/:id", (req, res) => { app.get("/workshop/:structure_id/route/:id", (req, res) => {
const route = getRoute(db, req.params.id); const route = getRoute(db, req.params.id);
const routePrefix = getStructure(db, req.params.structure_id).route_prefix; let routePrefix = getStructure(db, req.params.structure_id).route_prefix;
let previewUrl = routePrefix let previewUrl = routePrefix
? path.join(routePrefix || "", route.path) ? path.join(routePrefix || "", route.path)
: route.path; : route.path;
console.log(previewUrl, "baba");
// let previewUrl = prefixUrlWithHost(
// req,
// routePrefix ? path.join(routePrefix.substring(1), route.path) : route.path
// );
console.log(previewUrl);
return res.send( return res.send(
bootstrapTemplateWithHTMXetc( bootstrapTemplateWithHTMXetc(
eta.render("workshop/route", { eta.render("workshop/route", {
@ -873,12 +960,20 @@ app.get("/workshop/:structure_id/settings", (req, res) => {
}); });
app.put("/workshop/:structure_id/settings", (req, res) => { app.put("/workshop/:structure_id/settings", (req, res) => {
console.log(req.body)
let structId = req.params.structure_id; let structId = req.params.structure_id;
let struct = getStructure(db, structId); let struct = getStructure(db, structId);
let routePrefix = req.body.route_prefix;
if (routePrefix[0] != "/") {
routePrefix = "/" + routePrefix;
}
if (routePrefix[routePrefix.length - 1] == "/") {
routePrefix = routePrefix.substring(0, routePrefix.length - 1);
}
updateStruct(db, { updateStruct(db, {
...struct, ...struct,
route_prefix: req.body.route_prefix, route_prefix: routePrefix,
head_injection: req.body.head_injection, head_injection: req.body.head_injection,
}); });
struct = getStructure(db, structId); struct = getStructure(db, structId);
@ -919,7 +1014,6 @@ app.get("/workshop/:structure_id/new_db_modal", (req, res) => {
app.get("/workshop/:structure_id/clone_modal", (req, res) => { app.get("/workshop/:structure_id/clone_modal", (req, res) => {
const structure = getStructure(db, req.params.structure_id); const structure = getStructure(db, req.params.structure_id);
const dbs = getDbsForStructure(db, req.params.structure_id); const dbs = getDbsForStructure(db, req.params.structure_id);
console.log(dbs);
return res.send( return res.send(
bootstrapTemplateWithHTMXetc( bootstrapTemplateWithHTMXetc(
@ -956,35 +1050,18 @@ app.all("*", (req, res) => {
.prepare("SELECT * FROM routes WHERE id = ?") .prepare("SELECT * FROM routes WHERE id = ?")
.get(routeMatch.id); .get(routeMatch.id);
let dbs = getDbsForStructure(db, route.structure_id);
const structure = getStructure(db, route.structure_id); const structure = getStructure(db, route.structure_id);
const __urlPrefix = structure.route_prefix const __urlPrefix = structure.route_prefix;
console.log(structure)
const allDbInstances = {};
try { try {
let context = vm.createContext({ // todo: only add req res to contexst when running the handler()
// which means moving to runscript instead of runincontext for that
let context = bootstrapContext(db, route.structure_id, {
req, req,
res, res,
eta, eta,
getDb: function (alias) {
return allDbInstances[alias];
},
module: { exports: null },
}); });
for (let appDb of dbs) {
let dbInstance = getDbInstance(appDb.id);
context.sql = dbInstance;
vm.runInContext(appDb.library, context);
allDbInstances[appDb.alias] = {
library: context.module.exports,
sql: dbInstance,
};
context.module.exports = null;
}
res.render = (template, context) => { res.render = (template, context) => {
context = context || {}; context = context || {};
context.route = function (url) { context.route = function (url) {

View file

@ -46,10 +46,11 @@ CREATE TABLE templates (
CREATE TABLE routes ( CREATE TABLE routes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
verb TEXT CHECK(verb IN ('POST', 'GET', 'PUT', 'DELETE')) NOT NULL, verb TEXT CHECK(verb IN ('POST', 'GET', 'PUT', 'DELETE', 'WS')) NOT NULL,
path TEXT NOT NULL, path TEXT NOT NULL,
structure_id INTEGER, structure_id INTEGER,
handler TEXT NOT NULL, handler TEXT NOT NULL,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(structure_id) REFERENCES structures(id), FOREIGN KEY(structure_id) REFERENCES structures(id),

51
package-lock.json generated
View file

@ -20,6 +20,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-fileupload": "^1.5.0", "express-fileupload": "^1.5.0",
"express-session": "^1.18.0", "express-session": "^1.18.0",
"express-ws": "^5.0.2",
"jsdom": "^24.1.0", "jsdom": "^24.1.0",
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"nunjucks": "^3.2.4", "nunjucks": "^3.2.4",
@ -883,6 +884,40 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
}, },
"node_modules/express-ws": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz",
"integrity": "sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==",
"dependencies": {
"ws": "^7.4.6"
},
"engines": {
"node": ">=4.5.0"
},
"peerDependencies": {
"express": "^4.0.0 || ^5.0.0-alpha.1"
}
},
"node_modules/express-ws/node_modules/ws": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
"integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/express/node_modules/body-parser": { "node_modules/express/node_modules/body-parser": {
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
@ -3210,6 +3245,22 @@
} }
} }
}, },
"express-ws": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz",
"integrity": "sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==",
"requires": {
"ws": "^7.4.6"
},
"dependencies": {
"ws": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
"integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
"requires": {}
}
}
},
"file-uri-to-path": { "file-uri-to-path": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",

View file

@ -20,6 +20,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-fileupload": "^1.5.0", "express-fileupload": "^1.5.0",
"express-session": "^1.18.0", "express-session": "^1.18.0",
"express-ws": "^5.0.2",
"jsdom": "^24.1.0", "jsdom": "^24.1.0",
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"nunjucks": "^3.2.4", "nunjucks": "^3.2.4",

View file

@ -10,7 +10,7 @@
<form hx-post="/workshop/<%= it.structure.id %>/route" hx-target="#response"> <form hx-post="/workshop/<%= it.structure.id %>/route" hx-target="#response">
<div class="field-row-stacked" style="width: 300px"> <div class="field-row-stacked" style="width: 300px">
<label for="path">Path (e.g. /foo/bar)</label> <label for="path">Path (e.g. /foo/bar)</label>
<div class="flex items-center"><%= it.structure.route_prefix %>/<input name="path" id="name" type="text" class="flex-grow" /></div> <div class="flex items-center"><%= it.structure.route_prefix || "" %>/<input name="path" id="name" type="text" class="flex-grow" /></div>
</div> </div>
<fieldset> <fieldset>
<legend>verb</legend> <legend>verb</legend>
@ -30,6 +30,10 @@
<input id="delete" type="radio" name="verb" value="DELETE"> <input id="delete" type="radio" name="verb" value="DELETE">
<label for="delete">DELETE</label> <label for="delete">DELETE</label>
</div> </div>
<div class="field-row">
<input id="ws" type="radio" name="verb" value="WS">
<label for="ws">WS</label>
</div>
</fieldset> </fieldset>
<section class="field-row" style="justify-content: flex-end"> <section class="field-row" style="justify-content: flex-end">
<button type="submit">OK</button> <button type="submit">OK</button>

View file

@ -7,7 +7,7 @@
<li> <li>
<a href="/workshop/<%= it.structure.id %>/route/<%= route.id %>"> <a href="/workshop/<%= it.structure.id %>/route/<%= route.id %>">
<%= route.verb %> - <%= route.verb %> -
<% if (it.structure.route_prefix) { %><span class="text-gray-500"><%= it.structure.route_prefix %></span><% } %><span class="bold"><%= route.path %></span> <% if (it.structure.route_prefix) { %><span class="text-gray-500"><%= it.structure.route_prefix %></span><% } %><span class="bold"><%= route.path %></span><% if (route.error) { %>⚠️<% } %>
</a> </a>
</li> </li>
<% }) %> <% }) %>