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

227
index.js
View file

@ -8,9 +8,11 @@ const SQLiteStore = require("better-sqlite3-session-store")(session);
const betterSqlite3 = require("better-sqlite3");
const { Eta } = require("eta");
const { match } = require("path-to-regexp");
const { LRUCache } = require("lru-cache");
const bcrypt = require("bcrypt");
const cheerio = require("cheerio");
const app = express();
const _expressWs = require("express-ws")(app);
const bodyParser = require("body-parser");
const PORT = 3000;
@ -27,6 +29,39 @@ const db = betterSqlite3("./dbs/0.sqlite");
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) {
return db
.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() {
let newRoutes = {
GET: [],
@ -87,14 +177,17 @@ function buildRoutes() {
};
for (let route of getAllRoutes(db)) {
const prefix = route.route_prefix;
const p = prefix ? path.join(route.route_prefix, route.path) : route.path;
const p = routeWithPrefix(route);
if (route.verb == "WS") {
bootstrapWebsocketHandler(route);
} else {
newRoutes[route.verb].push({
matcher: match(p, { decode: decodeURIComponent }),
id: route.id,
path: route.path,
});
}
}
routes = newRoutes;
}
@ -192,6 +285,7 @@ function createStructure(db, name, userId) {
}
function createRoute(db, verb, path, structureId, handler) {
path = encodeURI(path)
// add default handler here
const stmt = db.prepare(
"INSERT INTO routes (verb, path, structure_id, handler) VALUES (?, ?, ?, ?)"
@ -211,7 +305,14 @@ function getRoute(db, routeId) {
}
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 placeholders = fields.map((field) => `${field} = ?`).join(", ");
@ -232,7 +333,6 @@ function updateDb(db, appDb) {
function updateStruct(db, struct) {
const fields = ["name", "route_prefix", "head_injection"];
console.log(struct, fields);
const values = fields.map((field) => struct[field]);
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")
.all(structureId);
console.log(db.prepare("PRAGMA table_info(files)").all());
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;
}
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(
structId,
@ -436,8 +501,6 @@ function cloneStructure(
const toClone = new Set(cloneDbs);
const toAlias = new Set();
console.log(toClone, dbIds);
for (let { db_id } of dbIds) {
if (!toClone.has(db_id.toString())) {
toAlias.add(db_id.toString());
@ -504,10 +567,13 @@ function bootstrapTemplateWithHTMXetc(
blissClone,
blissCopy,
headInjection,
htmxRequest=false
htmxRequest = false
) {
if (htmlString.toLowerCase().startsWith("<html>") || htmlString.toLowerCase().startsWith("<!doctype") || !htmxRequest) {
console.log("grow")
if (
htmlString.toLowerCase().startsWith("<html>") ||
htmlString.toLowerCase().startsWith("<!doctype") ||
!htmxRequest
) {
const $ = cheerio.load(htmlString);
let head = $("head");
@ -516,12 +582,13 @@ function bootstrapTemplateWithHTMXetc(
head = $("head");
}
head.attr("id", "head")
head.attr("id", "head");
head.append(`
<script src="/js/hyperscript.js"></script>
<script src="/js/tailwind.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>
${headInjection || ""}
`);
@ -533,13 +600,12 @@ function bootstrapTemplateWithHTMXetc(
}
htmlString = $.html();
console.log(htmlString)
} else if (htmxRequest && blissRoute) {
const $ = cheerio.load(htmlString, null, false);
$.root().children().attr("data-bliss-route", blissRoute);
$.root().children().attr("data-bliss-clone", blissClone);
if (blissCopy) $.root().children().attr("data-bliss-copy", blissCopy);
htmlString = $.html()
htmlString = $.html();
if (htmxRequest) {
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) => {
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(
db,
req.body.verb,
path[0] == "/" ? path : "/" + path,
p[0] == "/" ? p.substring(1) : "/" + p,
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
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) => {
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
? path.join(routePrefix || "", 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(
bootstrapTemplateWithHTMXetc(
eta.render("workshop/route", {
@ -873,12 +960,20 @@ app.get("/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 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, {
...struct,
route_prefix: req.body.route_prefix,
route_prefix: routePrefix,
head_injection: req.body.head_injection,
});
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) => {
const structure = getStructure(db, req.params.structure_id);
const dbs = getDbsForStructure(db, req.params.structure_id);
console.log(dbs);
return res.send(
bootstrapTemplateWithHTMXetc(
@ -956,35 +1050,18 @@ app.all("*", (req, res) => {
.prepare("SELECT * FROM routes WHERE id = ?")
.get(routeMatch.id);
let dbs = getDbsForStructure(db, route.structure_id);
const structure = getStructure(db, route.structure_id);
const __urlPrefix = structure.route_prefix
console.log(structure)
const allDbInstances = {};
const __urlPrefix = structure.route_prefix;
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,
res,
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) => {
context = context || {};
context.route = function (url) {

View file

@ -46,10 +46,11 @@ CREATE TABLE templates (
CREATE TABLE routes (
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,
structure_id INTEGER,
handler TEXT NOT NULL,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(structure_id) REFERENCES structures(id),

51
package-lock.json generated
View file

@ -20,6 +20,7 @@
"express": "^4.18.2",
"express-fileupload": "^1.5.0",
"express-session": "^1.18.0",
"express-ws": "^5.0.2",
"jsdom": "^24.1.0",
"lru-cache": "^10.2.0",
"nunjucks": "^3.2.4",
@ -883,6 +884,40 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"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": {
"version": "1.20.1",
"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": {
"version": "1.0.0",
"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-fileupload": "^1.5.0",
"express-session": "^1.18.0",
"express-ws": "^5.0.2",
"jsdom": "^24.1.0",
"lru-cache": "^10.2.0",
"nunjucks": "^3.2.4",

View file

@ -10,7 +10,7 @@
<form hx-post="/workshop/<%= it.structure.id %>/route" hx-target="#response">
<div class="field-row-stacked" style="width: 300px">
<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>
<fieldset>
<legend>verb</legend>
@ -30,6 +30,10 @@
<input id="delete" type="radio" name="verb" value="DELETE">
<label for="delete">DELETE</label>
</div>
<div class="field-row">
<input id="ws" type="radio" name="verb" value="WS">
<label for="ws">WS</label>
</div>
</fieldset>
<section class="field-row" style="justify-content: flex-end">
<button type="submit">OK</button>

View file

@ -7,7 +7,7 @@
<li>
<a href="/workshop/<%= it.structure.id %>/route/<%= route.id %>">
<%= 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>
</li>
<% }) %>