From 618842883d76136ef78d439addee571c68862dde Mon Sep 17 00:00:00 2001 From: octospacc Date: Fri, 23 Feb 2024 01:38:08 +0100 Subject: [PATCH] Update deps, add MVP Server variant, + Example --- Example.Server.html | 16 +++ Example.Server.js | 49 ++++++++ Example.css | 11 ++ SpaccDotWeb.Alt.js | 1 + SpaccDotWeb.Server.js | 266 ++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 11 +- package.json | 4 + 7 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 Example.Server.html create mode 100644 Example.Server.js create mode 100644 Example.css create mode 100644 SpaccDotWeb.Server.js diff --git a/Example.Server.html b/Example.Server.html new file mode 100644 index 0000000..1b367a8 --- /dev/null +++ b/Example.Server.html @@ -0,0 +1,16 @@ +
+ + + +
\ No newline at end of file diff --git a/Example.Server.js b/Example.Server.js new file mode 100644 index 0000000..8540bc3 --- /dev/null +++ b/Example.Server.js @@ -0,0 +1,49 @@ +const SpaccDotWebServer = require('./SpaccDotWeb.Server.js')({ + appName: 'Example', + // staticPrefix: '/static/', + // staticFiles: [], + linkStyles: [ 'Example.css' ], + // linkScripts: [], + // htmlPager: htmlPager(content) => `...`, +}); + +if (process && process.argv[2] === 'html') { + SpaccDotWebServer.writeStaticHtml(__filename); +} else { + SpaccDotWebServer.initServer({ + // defaultResponse: { code: 500, headers: {} }, + // endpointsFalltrough: false, + // port: 3000, + // address: '127.0.0.1', + // maxBodyUploadSize: null, + // appElement: 'div#app', + // transitionElement: 'div#transition', + endpoints: [ + [ (ctx) => (['GET', 'POST'].includes(ctx.request.method) && ctx.request.url.toLowerCase().startsWith('/main/')), (ctx) => { + if (ctx.request.method === 'POST') { + if (ctx.bodyParameters?.add) { + ctx.setCookie(`count=${parseInt(ctx.getCookie('count') || 0) + 1}`); + } else if (ctx.bodyParameters?.reset) { + ctx.setCookie(`count=`); + }; + }; + const content = ` +

Test

+ ${ctx.request.method === 'POST' ? `

POST body parameters:

${JSON.stringify(ctx.bodyParameters)}
` : ''} +

This page was rendered at ${Date()}.

+

These were your cookies at time of request:

+
${ctx.getCookie()}
+
+ + +
+ `; + ctx.renderPage(content); + // return { code: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: content } + } ], + + [ (ctx) => (ctx.request.method === 'GET'), (ctx) => ctx.redirectTo('/main/') ], + // [ (ctx) => (ctx.request.method === 'GET'), (ctx) => ({ code: 302, headers: { location: '/main/' } }) ], + ], + }); +}; diff --git a/Example.css b/Example.css new file mode 100644 index 0000000..6af4cf9 --- /dev/null +++ b/Example.css @@ -0,0 +1,11 @@ +body { + background-color: lightgray; + color: black; +} + +h2 { + position: relative; + top: 3em; + rotate: 15deg; + color: purple; +} diff --git a/SpaccDotWeb.Alt.js b/SpaccDotWeb.Alt.js index 681909a..3fddc79 100644 --- a/SpaccDotWeb.Alt.js +++ b/SpaccDotWeb.Alt.js @@ -21,6 +21,7 @@ document.body.appendChild(scriptElem); }); } + // .RequireScripts = (...) => {} SpaccDotWeb.ShowModal = async (params) => { // TODO: delete dialogs from DOM after use (garbage collect)? diff --git a/SpaccDotWeb.Server.js b/SpaccDotWeb.Server.js new file mode 100644 index 0000000..bec0381 --- /dev/null +++ b/SpaccDotWeb.Server.js @@ -0,0 +1,266 @@ +(() => { + const envIsNode = (typeof module === 'object' && typeof module.exports === 'object'); + const envIsBrowser = (typeof window !== 'undefined' && typeof window.document !== 'undefined'); + let fs, path, mime, multipart; + const allOpts = {}; + + const main = (globalOptions={}) => { + allOpts.global = globalOptions; + allOpts.global.staticPrefix ||= '/static/'; + allOpts.global.staticFiles ||= []; + allOpts.global.linkStyles ||= []; + allOpts.global.linkScripts ||= []; + for (const item of [...allOpts.global.linkStyles, ...allOpts.global.linkScripts]) { + const itemLow = item.toLowerCase(); + if (!(itemLow.startsWith('http://') || itemLow.startsWith('https://') || itemLow.startsWith('/'))) { + allOpts.global.staticFiles.push(item); + }; + }; + allOpts.global.htmlPager ||= (content) => `${allOpts.global.linkStyles.map((item) => { + return ``; + }).join('')}
${content}
`; + const result = {}; + result.initServer = (serverOptions={}) => initServer(serverOptions); + if (envIsNode) { + result.writeStaticHtml = (mainScriptPath) => writeStaticHtml(mainScriptPath); + }; + return result; + }; + + const initServer = (serverOptions) => { + allOpts.server = serverOptions; + allOpts.server.defaultResponse ||= { code: 500, headers: {} }; + allOpts.server.endpointsFalltrough ||= false; + if (envIsNode) { + allOpts.server.port ||= 3000; + allOpts.server.address ||= '127.0.0.1'; + allOpts.server.maxBodyUploadSize = (parseInt(allOpts.server.maxBodyUploadSize) || undefined); + require('http').createServer(handleRequest).listen(allOpts.server.port, allOpts.server.address); + }; + if (envIsBrowser) { + allOpts.server.appElement ||= 'div#app'; + allOpts.server.transitionElement ||= 'div#transition'; + const navigatePage = () => handleRequest({ url: window.location.hash.slice(1), method: 'GET' }); + window.addEventListener('hashchange', () => { + window.location.hash ||= '/'; + navigatePage(); + }); + navigatePage(); + }; + }; + + const writeStaticHtml = (mainScriptPath) => { + fs.writeFileSync((mainScriptPath.split('.').slice(0, -1).join('.') + '.html'), allOpts.global.htmlPager(` + + + + `)); + }; + + const handleRequest = async (request, response={}) => { + // build request context and handle special tasks + let result = allOpts.server.defaultResponse; + const context = { + request, + response, + urlParameters: (new URLSearchParams(request.url.split('?')[1]?.join('?'))), + bodyParameters: (request.method === 'POST' && await parseBodyParams(request)), // TODO which other methods need body? + getCookie: (cookie) => getCookie(request, cookie), + setCookie: (cookie) => setCookie(response, cookie), + renderPage: (content) => renderPage(response, content), + redirectTo: (url) => redirectTo(response, url), + }; + // serve static files + if (envIsNode && request.method === 'GET' && request.url.toLowerCase().startsWith(allOpts.global.staticPrefix)) { + const resPath = request.url.split(allOpts.global.staticPrefix).slice(1).join(allOpts.global.staticPrefix); + const filePath = (__dirname + path.sep + resPath); // TODO i think we need to read this another way if the module is in a different directory from the importing program + if (allOpts.global.staticFiles.includes(resPath) && fs.existsSync(filePath)) { + result = { code: 200, headers: { 'content-type': mime.lookup(filePath) }, body: fs.readFileSync(filePath) }; + } else { + result = { code: 404 }; + }; + } else { + // handle custom endpoints + for (const [check, procedure] of allOpts.server.endpoints) { + if (check(context)) { + result = await procedure(context); + if (!allOpts.server.endpointsFalltrough) { + break; + }; + }; + }; + }; + // finalize a normal response + if (result) { + response.statusCode = result.code; + for (const name in result.headers) { + response.setHeader(name, result.headers[name]); + }; + response.end(result.body); + }; + }; + + const renderPage = (response, content) => { + // TODO titles and things + // TODO status code could need to be different in different situations and so should be set accordingly? + if (envIsNode) { + response.setHeader('content-type', 'text/html; charset=utf-8'); + response.end(allOpts.global.htmlPager(content)); + }; + if (envIsBrowser) { + document.querySelector(allOpts.server.appElement).innerHTML = content; + for (const srcElem of document.querySelectorAll(`[src^="${allOpts.global.staticPrefix}"]`)) { + srcElem.src = resFilesData[srcElem.getAttribute('src')]; + }; + for (const linkElem of document.querySelectorAll(`link[rel="stylesheet"][href^="${allOpts.global.staticPrefix}"]`)) { + linkElem.href = resFilesData[linkElem.getAttribute('href').slice(allOpts.global.staticPrefix.length)]; + }; + for (const aElem of document.querySelectorAll('a[href^="/"]')) { + aElem.href = `#${aElem.getAttribute('href')}`; + }; + for (const formElem of document.querySelectorAll('form')) { + formElem.onsubmit = (event) => { + event.preventDefault(); + const formData = (new FormData(formElem)); + formData.append(event.submitter.getAttribute('name'), (event.submitter.value || 'Submit')); + handleRequest({ + method: (formElem.getAttribute('method') || 'GET'), + url: (formElem.getAttribute('action') || location.hash.slice(1)), + headers: { 'content-type': (formElem.getAttribute('enctype') || "application/x-www-form-urlencoded") }, + body: formData, + }); + }; + }; + }; + }; + + const redirectTo = (response, url) => { + if (envIsNode) { + response.statusCode = 302; + response.setHeader('location', url); + response.end(); + }; + if (envIsBrowser) { + location.hash = url; + }; + }; + + const parseBodyParams = async (request) => { + try { + let params = {}; + if (envIsNode) { + request.body = Buffer.alloc(0); + request.on('data', (data) => { + request.body = Buffer.concat([request.body, data]); + if (request.body.length > allOpts.server.maxBodyUploadSize) { + request.connection?.destroy(); + // TODO handle this more gracefully? maybe an error callback or something? + }; + }); + await new Promise((resolve) => request.on('end', () => resolve())); + }; + const [contentMime, contentEnc] = request.headers['content-type'].split(';'); + if (envIsNode && contentMime === 'application/x-www-form-urlencoded') { + for (const [key, value] of (new URLSearchParams(request.body.toString())).entries()) { + params[key] = value; + }; + } else if (envIsNode && contentMime === 'multipart/form-data') { + for (const param of multipart.parse(request.body, contentEnc.split('boundary=')[1])) { + params[param.name] = (param.type && param.filename !== undefined ? param : param.data.toString()); + }; + } else if (envIsBrowser && ['application/x-www-form-urlencoded', 'multipart/form-data'].includes(contentMime)) { + for (const [key, value] of request.body) { + params[key] = value; + params[key].filename = params[key].name; + }; + }; + return params; + } catch (err) { + console.log(err); + request.connection?.destroy(); + }; + }; + + const getCookie = (request, name) => { + let cookies; + if (envIsNode) { + cookies = request.headers?.cookie; + }; + if (envIsBrowser) { + cookies = clientCookieApi(); + }; + if (name) { + // get a specific cookie + for (const cookie of (cookies?.split(';') || [])) { + const [key, ...rest] = cookie.split('='); + if (key === name) { + return rest.join('='); + }; + }; + } else { + // get all cookies + return cookies; + }; + }; + + const setCookie = (response, cookie) => { + if (envIsNode) { + response.setHeader('Set-Cookie', cookie); + // TODO update current cookie list in existing request to reflect new assignment + }; + if (envIsBrowser) { + clientCookieApi(cookie); + }; + }; + + // try to use the built-in cookie API, fallback to a Storage-based wrapper in case it fails (for example on file:///) + const clientCookieApi = (envIsBrowser && (document.cookie || (!document.cookie && (document.cookie = '_=_') && document.cookie) ? (set) => (set ? (document.cookie = set) : document.cookie) : (set) => { + const gid = allOpts.global.appName; // TODO: introduce a conf field that is specifically a GID for less potential problems? + if (set) { + let api = sessionStorage; + const tokens = set.split(';'); + const [key, ...rest] = tokens[0].split('='); + for (let token of tokens) { + token = token.trim(); + if (['expires', 'max-age'].includes(token.split('=')[0].toLowerCase())) { + api = localStorage; + break; + }; + }; + api.setItem(`${gid}/${key}`, rest.join('=')); + } else /*(get)*/ { + let items = ''; + for (const item of Object.entries({ ...localStorage, ...sessionStorage })) { + if (item[0].startsWith(`${gid}/`)) { + items += (item.join('=') + ';').slice(gid.length + 1); + }; + } + return items.slice(0, -1); + }; + })); + + if (envIsNode) { + fs = require('fs'); + path = require('path'); + mime = require('mime-types'); + multipart = require('parse-multipart-data'); + module.exports = main; + }; + if (envIsBrowser) { + window.SpaccDotWebServer = main; + }; +})(); diff --git a/package-lock.json b/package-lock.json index a5acefe..cec180f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "SpaccDotWeb", "version": "indev", + "dependencies": { + "mime-types": "^2.1.35", + "parse-multipart-data": "^1.5.0" + }, "devDependencies": { "@babel/cli": "^7.23.0", "@babel/core": "^7.23.0", @@ -2573,7 +2577,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -2582,7 +2585,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -2639,6 +2641,11 @@ "wrappy": "1" } }, + "node_modules/parse-multipart-data": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/parse-multipart-data/-/parse-multipart-data-1.5.0.tgz", + "integrity": "sha512-ck5zaMF0ydjGfejNMnlo5YU2oJ+pT+80Jb1y4ybanT27j+zbVP/jkYmCrUGsEln0Ox/hZmuvgy8Ra7AxbXP2Mw==" + }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", diff --git a/package.json b/package.json index 65c21e4..fea5ac0 100644 --- a/package.json +++ b/package.json @@ -13,5 +13,9 @@ "dialog-polyfill": "^0.5.6", "jsdom": "^22.1.0", "uglify-js": "^3.17.4" + }, + "dependencies": { + "mime-types": "^2.1.35", + "parse-multipart-data": "^1.5.0" } }