diff --git a/Example.Server.html b/Example.Server.html index 1b367a8..f5ec366 100644 --- a/Example.Server.html +++ b/Example.Server.html @@ -1,16 +1,16 @@ Example
- +
\ No newline at end of file diff --git a/Example.Server.js b/Example.Server.js index 8540bc3..1fe8489 100644 --- a/Example.Server.js +++ b/Example.Server.js @@ -1,16 +1,20 @@ -const SpaccDotWebServer = require('./SpaccDotWeb.Server.js')({ +const SpaccDotWebServer = require('./SpaccDotWeb.Server.js'); +const server = SpaccDotWebServer.setup({ appName: 'Example', // staticPrefix: '/static/', // staticFiles: [], linkStyles: [ 'Example.css' ], // linkScripts: [], - // htmlPager: htmlPager(content) => `...`, + // pageTitler: pageTitler(title) => `...`, + // appPager: appPager(content, title) => `...`, + // htmlPager: htmlPager(content, title) => `...`, }); -if (process && process.argv[2] === 'html') { - SpaccDotWebServer.writeStaticHtml(__filename); +if (SpaccDotWebServer.envIsNode && process.argv[2] === 'html') { + server.writeStaticHtml(); + console.log('Dumped Static HTML!'); } else { - SpaccDotWebServer.initServer({ + server.initServer({ // defaultResponse: { code: 500, headers: {} }, // endpointsFalltrough: false, // port: 3000, @@ -19,7 +23,7 @@ if (process && process.argv[2] === 'html') { // appElement: 'div#app', // transitionElement: 'div#transition', endpoints: [ - [ (ctx) => (['GET', 'POST'].includes(ctx.request.method) && ctx.request.url.toLowerCase().startsWith('/main/')), (ctx) => { + [ (ctx) => (['GET', 'POST'].includes(ctx.request.method) && ctx.urlSections[0] === 'main'), (ctx) => { if (ctx.request.method === 'POST') { if (ctx.bodyParameters?.add) { ctx.setCookie(`count=${parseInt(ctx.getCookie('count') || 0) + 1}`); @@ -32,13 +36,13 @@ if (process && process.argv[2] === 'html') { ${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.getCookie() || '[None]'}
`; - ctx.renderPage(content); + ctx.renderPage(content, 'Test'); // return { code: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: content } } ], @@ -46,4 +50,5 @@ if (process && process.argv[2] === 'html') { // [ (ctx) => (ctx.request.method === 'GET'), (ctx) => ({ code: 302, headers: { location: '/main/' } }) ], ], }); + console.log('Running Server...'); }; diff --git a/Example.css b/Example.css index 6af4cf9..90be467 100644 --- a/Example.css +++ b/Example.css @@ -4,8 +4,7 @@ body { } h2 { - position: relative; - top: 3em; + width: max-content; rotate: 15deg; - color: purple; + color: DeepPink; } diff --git a/SpaccDotWeb.Server.js b/SpaccDotWeb.Server.js index bec0381..92d28cd 100644 --- a/SpaccDotWeb.Server.js +++ b/SpaccDotWeb.Server.js @@ -1,266 +1,294 @@ +/* TODO: + * built-in logging + * other things listed in the file + */ (() => { - 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); - }; +const envIsNode = (typeof module === 'object' && typeof module.exports === 'object'); +const envIsBrowser = (typeof window !== 'undefined' && typeof window.document !== 'undefined'); +const allOpts = {}; +let fs, path, mime, multipart; + +const setup = (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; }; + allOpts.global.pageTitler ||= (title) => `${title || ''}${title && allOpts.global.appName ? ' — ' : ''}${allOpts.global.appName || ''}`, + allOpts.global.appPager ||= (content, title) => content, + allOpts.global.htmlPager ||= (content, title) => `${allOpts.global.pageTitler(title)}${allOpts.global.linkStyles.map((item) => { + return ``; + }).join('')}
${allOpts.global.appPager(content, title)}
`; + const result = {}; + result.initServer = (serverOptions={}) => initServer(serverOptions); + if (envIsNode) { + result.writeStaticHtml = writeStaticHtml; + }; + 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(); - }); +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 writeStaticHtml = () => { + // TODO: fix script paths + // TODO: this should somehow set envIsBrowser to true to maybe allow for correct template rendering, but how to do it without causing race conditions? maybe we should expose another variable + fs.writeFileSync((process.mainModule.filename.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, + urlSections: request.url.slice(1).toLowerCase().split('?')[0].split('/'), + 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, title) => renderPage(response, content, title), + redirectTo: (url) => redirectTo(response, url), }; - - 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 }; - }; + // client transitions + if (envIsBrowser && document.querySelector(allOpts.server.transitionElement)) { + document.querySelector(allOpts.server.transitionElement).style.display = 'block'; + }; + // 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 = (process.mainModule.path + 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 { - // handle custom endpoints - for (const [check, procedure] of allOpts.server.endpoints) { - if (check(context)) { - result = await procedure(context); - if (!allOpts.server.endpointsFalltrough) { - break; - }; - }; - }; + result = { code: 404 }; }; - // 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; + } else { + // handle custom endpoints + for (const [check, procedure] of allOpts.server.endpoints) { + if (await check(context)) { + result = await procedure(context); + if (!allOpts.server.endpointsFalltrough) { 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); }; - })); + }; + // 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, title) => { + // TODO titles and things + // TODO status code could need to be different in different situations and so should be set accordingly? if (envIsNode) { - fs = require('fs'); - path = require('path'); - mime = require('mime-types'); - multipart = require('parse-multipart-data'); - module.exports = main; + response.setHeader('content-type', 'text/html; charset=utf-8'); + response.end(allOpts.global.htmlPager(content, title)); }; if (envIsBrowser) { - window.SpaccDotWebServer = main; + document.title = allOpts.global.pageTitler(title); + document.querySelector(allOpts.server.appElement).innerHTML = allOpts.global.appPager(content, title); + for (const srcElem of document.querySelectorAll(`[src^="${allOpts.global.staticPrefix}"]`)) { + srcElem.src = window.SpaccDotWebServer.resFilesData[srcElem.getAttribute('src')]; + }; + for (const linkElem of document.querySelectorAll(`link[rel="stylesheet"][href^="${allOpts.global.staticPrefix}"]`)) { + linkElem.href = window.SpaccDotWebServer.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, + }); + }; + }; + if (document.querySelector(allOpts.server.transitionElement)) { + document.querySelector(allOpts.server.transitionElement).style.display = 'none'; + }; }; +}; + +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? + // also, TODO: what to do when no app name or any id is set? + if (set) { + let api = sessionStorage; + const tokens = set.split(';'); + let [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; + }; + }; + key = `${gid}/${key}`; + const value = rest.join('='); + if (value) { + api.setItem(key, value); + } else { + sessionStorage.removeItem(key); + localStorage.removeItem(key); + }; + } 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); + }; +})); + +const exportObj = { envIsNode, envIsBrowser, setup }; +if (envIsNode) { + fs = require('fs'); + path = require('path'); + mime = require('mime-types'); + multipart = require('parse-multipart-data'); + module.exports = exportObj; +}; +if (envIsBrowser) { + window.SpaccDotWebServer = exportObj; +}; + })(); diff --git a/package-lock.json b/package-lock.json index cec180f..3ac7123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "SpaccDotWeb", - "version": "indev", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "SpaccDotWeb", - "version": "indev", + "version": "0.2.1", "dependencies": { "mime-types": "^2.1.35", "parse-multipart-data": "^1.5.0"