diff --git a/.gitignore b/.gitignore index f3d7c83..3ea4f5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.tmp -Build/* -node_modules/* +/Build/* +/node_modules/* +/Example.Server.html diff --git a/Example.Server.html b/Example.Server.html deleted file mode 100644 index f5ec366..0000000 --- a/Example.Server.html +++ /dev/null @@ -1,16 +0,0 @@ -Example
- - - -
\ No newline at end of file diff --git a/Example.Server.js b/Example.Server.js old mode 100644 new mode 100755 index 1fe8489..44f2f89 --- a/Example.Server.js +++ b/Example.Server.js @@ -1,3 +1,4 @@ +#!/usr/bin/env node const SpaccDotWebServer = require('./SpaccDotWeb.Server.js'); const server = SpaccDotWebServer.setup({ appName: 'Example', @@ -5,16 +6,16 @@ const server = SpaccDotWebServer.setup({ // staticFiles: [], linkStyles: [ 'Example.css' ], // linkScripts: [], - // pageTitler: pageTitler(title) => `...`, - // appPager: appPager(content, title) => `...`, - // htmlPager: htmlPager(content, title) => `...`, + // pageTitler: (title) => `...`, + // appPager: (content, title) => `...`, + // htmlPager: (content, title) => `...`, }); -if (SpaccDotWebServer.envIsNode && process.argv[2] === 'html') { - server.writeStaticHtml(); - console.log('Dumped Static HTML!'); +if (SpaccDotWebServer.envIsNode && ['dump', 'html'].includes(process.argv[2])) { + const fileName = server.writeStaticHtml(); + console.log(`Dumped Static HTML to '${fileName}'!`); } else { - server.initServer({ + const serverData = server.initServer({ // defaultResponse: { code: 500, headers: {} }, // endpointsFalltrough: false, // port: 3000, @@ -22,18 +23,32 @@ if (SpaccDotWebServer.envIsNode && process.argv[2] === 'html') { // maxBodyUploadSize: null, // appElement: 'div#app', // transitionElement: 'div#transition', + + // endpoints are defined by a discriminator and an action endpoints: [ - [ (ctx) => (['GET', 'POST'].includes(ctx.request.method) && ctx.urlSections[0] === 'main'), (ctx) => { + + // a discriminator can be a simple boolean function + [ (ctx) => { + const now = (new Date); + return (['GET', 'POST'].includes(ctx.request.method) && now.getHours() === 0 && now.getMinutes() === 0); + }, (ctx) => ctx.renderPage(`

We're sorry but, to avoid disturbing the spirits, Testing is not available at 00:00. Please retry in just a minute.

`, 'Error') ], + + // or, a discriminator can be a specially-constructed filter string + [ 'GET|POST /main/', async (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}`); } else if (ctx.bodyParameters?.reset) { ctx.setCookie(`count=`); - }; - }; + } + + // a short sleep so that we can test client transitions + await (new Promise(r => setTimeout(r, 1500))); + } + // TODO: setCookie should update the current cookie context, so that following getCookie calls return updated data 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() || '[None]'}
@@ -41,14 +56,28 @@ if (SpaccDotWebServer.envIsNode && process.argv[2] === 'html') { +

Context data for this request:

+
${JSON.stringify({
+						request: {
+							method: ctx.request.method,
+						},
+						urlSections: ctx.urlSections,
+						urlParameters: ctx.urlParameters,
+						bodyParameters: ctx.bodyParameters,
+					}, null, 2)}
`; + // the main content of a page can be rendered with the main template using: ctx.renderPage(content, 'Test'); - // 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/' } }) ], + // redirects are easy + [ 'GET', (ctx) => { + ctx.redirectTo('/main/'); + // alternatively: return { code: 302, headers: { location: '/main/' } }; + } ], ], }); - console.log('Running Server...'); + if (SpaccDotWebServer.envIsNode) { + console.log(`Running Server on <${serverData.address}:${serverData.port}>...`); + } }; diff --git a/Example.css b/Example.css index 90be467..15ef091 100644 --- a/Example.css +++ b/Example.css @@ -3,8 +3,21 @@ body { color: black; } -h2 { +div#app > h2 { width: max-content; rotate: 15deg; color: DeepPink; } + +div#transition { + width: 100vw; + height: 100vh; + position: absolute; + top: 0; + left: 0; + z-index: 1; + background: black; + opacity: 0.25; + cursor: progress; + display: none; +} diff --git a/SpaccDotWeb.Server.js b/SpaccDotWeb.Server.js index 92d28cd..22ad3ba 100644 --- a/SpaccDotWeb.Server.js +++ b/SpaccDotWeb.Server.js @@ -19,26 +19,26 @@ const setup = (globalOptions={}) => { const itemLow = item.toLowerCase(); if (!(itemLow.startsWith('http://') || itemLow.startsWith('https://') || itemLow.startsWith('/'))) { allOpts.global.staticFiles.push(item); - }; - }; + } + } 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)}${(opts.pageTitler || allOpts.global.pageTitler)(title)}${allOpts.global.linkStyles.map((item) => { return ``; }).join('')}
${allOpts.global.appPager(content, title)}
${(opts.appPager || allOpts.global.appPager)(content, title)}
`; const result = {}; result.initServer = (serverOptions={}) => initServer(serverOptions); if (envIsNode) { result.writeStaticHtml = writeStaticHtml; - }; + } return result; }; @@ -51,7 +51,7 @@ const initServer = (serverOptions) => { 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'; @@ -61,23 +61,26 @@ const initServer = (serverOptions) => { navigatePage(); }); navigatePage(); - }; + } + return allOpts.server; }; 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 fileName = (process.mainModule.filename.split('.').slice(0, -1).join('.') + '.html'); + fs.writeFileSync(fileName, allOpts.global.htmlPager(` `)); + return fileName; }; const handleRequest = async (request, response={}) => { @@ -87,66 +90,91 @@ const handleRequest = async (request, response={}) => { 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? + urlParameters: Object.fromEntries(new URLSearchParams(request.url.split('?')?.slice(1)?.join('?'))), + bodyParameters: (['POST', 'PUT', 'PATCH'].includes(request.method) && 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), + //storageApi: (key,value, opts) => storageApi(key, value, opts), + renderPage: (content, title, opts) => renderPage(response, content, title, opts), redirectTo: (url) => redirectTo(response, url), }; // client transitions - if (envIsBrowser && document.querySelector(allOpts.server.transitionElement)) { - document.querySelector(allOpts.server.transitionElement).style.display = 'block'; - }; + if (envIsBrowser) { + const transitionElement = document.querySelector(allOpts.server.transitionElement); + if (transitionElement) { + transitionElement.hidden = false; + 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)) { + 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 (await check(context)) { + if (await requestCheckFunction(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, title) => { +const requestCheckFunction = (check, context) => { + if (typeof check == 'function') { + return check(context); + } else if (typeof check == 'string') { + let [method, ...urlSections] = check.trim().split(' '); + urlSections = urlSections.join(' ').trim().split('/').slice(1, -1); + const methodCheck = (method === '*' || method.split('|').includes(context.request.method)); + let urlCheck = true; + for (const sectionIndex in urlSections) { + const urlSection = urlSections[sectionIndex]; + const checkSection = context.urlSections[sectionIndex]; + if (!['', '*', checkSection].includes(urlSection)) { + urlCheck = false; + break; + } + } + return (methodCheck && urlCheck); + } +}; + +const renderPage = (response, content, title, opts={}) => { // 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, title)); - }; + response.end((opts.htmlPager || allOpts.global.htmlPager)(content, title)); + } if (envIsBrowser) { - document.title = allOpts.global.pageTitler(title); - document.querySelector(allOpts.server.appElement).innerHTML = allOpts.global.appPager(content, title); + document.title = (opts.pageTitler || allOpts.global.pageTitler)(title); + document.querySelector(allOpts.server.appElement).innerHTML = ((opts.appPager || allOpts.global.appPager)(content, title)); for (const srcElem of document.querySelectorAll(`[src^="${allOpts.global.staticPrefix}"]`)) { - srcElem.src = window.SpaccDotWebServer.resFilesData[srcElem.getAttribute('src')]; - }; + srcElem.src = window.SpaccDotWebServer.staticFilesData[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)]; - }; + linkElem.href = window.SpaccDotWebServer.staticFilesData[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(); @@ -159,10 +187,12 @@ const renderPage = (response, content, title) => { body: formData, }); }; - }; - if (document.querySelector(allOpts.server.transitionElement)) { - document.querySelector(allOpts.server.transitionElement).style.display = 'none'; - }; + } + const transitionElement = document.querySelector(allOpts.server.transitionElement); + if (transitionElement) { + transitionElement.hidden = true; + transitionElement.style.display = 'none'; + } }; }; @@ -171,10 +201,10 @@ const redirectTo = (response, url) => { response.statusCode = 302; response.setHeader('location', url); response.end(); - }; + } if (envIsBrowser) { location.hash = url; - }; + } }; const parseBodyParams = async (request) => { @@ -190,22 +220,22 @@ const parseBodyParams = async (request) => { }; }); 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); @@ -217,32 +247,31 @@ const getCookie = (request, name) => { let cookies; if (envIsNode) { cookies = (request.headers?.cookie || ''); - }; - if (envIsBrowser) { + } else if (envIsBrowser) { cookies = clientCookieApi(); - }; + } if (name) { // get a specific cookie for (const cookie of (cookies?.split(';') || [])) { - const [key, ...rest] = cookie.split('='); + // TODO ensure this is good, whitespace must be removed at the start but idk about the end + const [key, ...rest] = cookie.trim().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) { + } else 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:///) @@ -258,8 +287,8 @@ const clientCookieApi = (envIsBrowser && (document.cookie || (!document.cookie & if (['expires', 'max-age'].includes(token.split('=')[0].toLowerCase())) { api = localStorage; break; - }; - }; + } + } key = `${gid}/${key}`; const value = rest.join('='); if (value) { @@ -267,16 +296,16 @@ const clientCookieApi = (envIsBrowser && (document.cookie || (!document.cookie & } 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 };