/* TODO: * built-in logging * relative URL root for redirects and internal functions? (or useless since HTML must be custom anyways?) * utility functions for rewriting url query parameters? * fix client hash navigation to prevent no-going-back issue (possible?) * finish polishing the cookie API * implement some nodejs `fs` functions for client-side * other things listed in the file */ (() => { 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; // 2 years is a good default duration for a system cookie // they say Firefox limits to that; Chromium forces expiry after 400 days // (https://http.dev/set-cookie#max-age) const cookieMaxAge2Years = (2 * 365 * 24 * 60 * 60); const setup = (globalOptions={}) => { allOpts.global = globalOptions; //allOpts.global.appName ||= 'Untitled SpaccDotWeb App'; //allOpts.global.appRoot ||= ''; //TODO allOpts.global.staticPrefix ||= '/static/'; //allOpts.global.staticRoot ||= ''; //TODO allOpts.global.staticFiles ||= []; allOpts.global.linkStyles ||= []; //allOpts.global.linkRuntimeScripts ||= []; //TODO allOpts.global.linkClientScripts ||= []; for (const item of [ ...allOpts.global.linkStyles, ...allOpts.global.linkClientScripts ]) { const itemLow = item.toLowerCase(); if (!(itemLow.startsWith('http://') || itemLow.startsWith('https://') || itemLow.startsWith('/'))) { allOpts.global.staticFiles.push(item); } } allOpts.global.pageTitler ||= (title, opts={}, context) => `${title || ''}${title && allOpts.global.appName ? ' — ' : ''}${allOpts.global.appName || ''}`, allOpts.global.appPager ||= (content, title, opts={}, context) => content, allOpts.global.htmlPager ||= (content, title, opts={}, context) => `${(opts.pageTitler || allOpts.global.pageTitler)(title, opts, context)}${allOpts.global.linkStyles.map((path) => makeHtmlStyleFragment(path, opts.selfContained)).join('')}${allOpts.global.linkClientScripts.map((path) => makeHtmlScriptFragment(path, opts.selfContained)).join('')}
${(opts.appPager || allOpts.global.appPager)(content, title, opts, context)}
`; 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; allOpts.server.metaCookie ||= 'spaccdotweb-meta'; if (envIsNode) { allOpts.server.port ||= 3000; allOpts.server.address ||= '127.0.0.1'; allOpts.server.maxBodyUploadSize = (parseInt(allOpts.server.maxBodyUploadSize) || undefined); allOpts.server.handleHttpHead ||= true; require('http').createServer(handleRequest).listen(allOpts.server.port, allOpts.server.address); } if (envIsBrowser) { allOpts.server.appElement ||= 'div#app'; allOpts.server.transitionElement ||= 'div#transition'; window.addEventListener('hashchange', () => { window.location.hash ||= '/'; navigateClientPage(); }); navigateClientPage(); } return allOpts.server; }; const navigateClientPage = (forceUrl) => ((!forceUrl || (window.location.hash === forceUrl)) && handleRequest({ url: window.location.hash.slice(1), method: 'GET' })); const writeStaticHtml = (selfContained=false) => { const appFilePath = process.mainModule.filename; const htmlFilePath = (appFilePath.split('.').slice(0, -1).join('.') + '.html'); // path.relative seems to always append an extra '../', so we must slice it const libraryPath = path.relative(appFilePath, __filename).split(path.sep).slice(1).join(path.sep); const libraryFolder = libraryPath.split(path.sep).slice(0, -1).join(path.sep); const context = { envIsNode: false, envIsBrowser: true }; fs.writeFileSync(htmlFilePath, allOpts.global.htmlPager(` ${makeHtmlScriptFragment(libraryPath, selfContained)} ${makeHtmlScriptFragment(((libraryFolder && (libraryFolder + '/')) + 'SpaccDotWeb.Alt.js'), selfContained)} <${'script'}> window.require = () => { window.require = async (src, type) => { await SpaccDotWeb.RequireScript((src.startsWith('./') ? src : ('node_modules/' + src)), type); }; return window.SpaccDotWebServer; }; window.SpaccDotWebServer.staticFilesData = { ${selfContained ? allOpts.global.staticFiles.map((file) => { // TODO check if these paths are correct or must still be fixed const filePath = (appFilePath.split(path.sep).slice(0, -1).join(path.sep) + path.sep + file); return `"${file}":"data:${mime.lookup(filePath)};base64,${fs.readFileSync(filePath).toString('base64')}"`; }).join() : ''} }; ${makeHtmlScriptFragment(path.basename(appFilePath), selfContained)} `, null, { selfContained, context }, context)); return htmlFilePath; }; const makeHtmlStyleFragment = (path, getContent) => { const data = getFilePathContent(path, getContent); return (data[1] ? `` : ``); }; const makeHtmlScriptFragment = (path, getContent) => { const data = getFilePathContent(path, getContent); return `<${'script'}${data[1] ? `>${data[1]}` : ` src="${data[0]}">`}`; }; const getFilePathContent = (path, getContent) => ([ (allOpts.global.staticFiles.includes(path) ? (allOpts.global.staticPrefix + path) : ('./' + path)), (getContent && fs.existsSync(path) && fs.readFileSync(path)), ]); const handleRequest = async (request, response={}) => { // build request context and handle special tasks let result = allOpts.server.defaultResponse; const context = { envIsNode, envIsBrowser, request, response, cookieString: (envIsNode ? (request.headers.cookie || '') : envIsBrowser ? clientCookieApi() : ''), clientLanguages: parseclientLanguages(request), urlPath: request.url.slice(1).toLowerCase().split('?')[0], urlQuery: request.url.split('?')?.slice(1)?.join('?'), bodyParameters: (['POST', 'PUT', 'PATCH'].includes(request.method) && await parseBodyParams(request)), // TODO which other methods need body? //storageApi: (key,value, opts) => storageApi(key, value, opts), redirectTo: (url) => redirectTo(response, url), }; context.renderPage = (content, title, opts) => renderPage(response, content, title, { ...opts, context }, context); context.urlSections = context.urlPath.split('/'); context.urlParameters = Object.fromEntries(new URLSearchParams(context.urlQuery)); context.cookieData = parseCookieString(context.cookieString); context.getCookie = (cookieName) => (cookieName ? context.cookieData[cookieName]?.value : context.cookieString); context.setCookie = (cookie, cookieValue, cookieFlags) => (context.cookieString = setCookie(context.cookieData, response, cookie, cookieValue, cookieFlags)); if (allOpts.server.metaCookie) { context.renewCookie = (cookieName) => renewCookie(context.getCookie, context.setCookie, cookieName); } setClientTransition(true); const handlingHttpHead = (allOpts.server.handleHttpHead && request.method === 'HEAD') if (handlingHttpHead) { request.method = 'GET'; } // 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 { result = { code: 404 }; } } else { // handle custom endpoints for (const [check, procedure] of allOpts.server.endpoints) { 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(!handlingHttpHead && result.body); } }; 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={}, context) => { // TODO titles and things // TODO status code could need to be different in different situations and so should be set accordingly? (but we don't handle it here?) if (envIsNode) { response.setHeader('content-type', 'text/html; charset=utf-8'); response.end((opts.htmlPager || allOpts.global.htmlPager)(content, title, opts, context)); } if (envIsBrowser) { document.title = (opts.pageTitler || allOpts.global.pageTitler)(title, opts, context); document.querySelector(allOpts.server.appElement).innerHTML = ((opts.appPager || allOpts.global.appPager)(content, title, opts, context)); for (const srcElem of document.querySelectorAll(`[src^="${allOpts.global.staticPrefix}"]`)) { var fileUrl = makeStaticClientFileUrl(srcElem.getAttribute('src')); if (srcElem.tagName === 'SCRIPT') { // script elements die immediately after being first set up, // so we must re-create them to have them run with correct uri srcElem.remove(); document.head.appendChild(Object.assign(document.createElement('script'), { src: fileUrl })); } else { srcElem.src = fileUrl; } } for (const linkElem of document.querySelectorAll(`link[rel="stylesheet"][href^="${allOpts.global.staticPrefix}"]`)) { linkElem.href = makeStaticClientFileUrl(linkElem.getAttribute('href')); } for (const aElem of document.querySelectorAll('a[href^="/"]')) { var url = ('#' + aElem.getAttribute('href')); aElem.href = url; // force navigation to page if the url is equal to current (refresh) aElem.addEventListener('click', () => { navigateClientPage(url); }); } 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, }); }; } setClientTransition(false); }; }; const makeStaticClientFileUrl = (url) => { url = url.slice(allOpts.global.staticPrefix.length); return (window.SpaccDotWebServer.staticFilesData[url] || ('./' + url)); }; const setClientTransition = (status) => { let transitionElement; if (envIsBrowser && (transitionElement = document.querySelector(allOpts.server.transitionElement))) { transitionElement.hidden = !status; transitionElement.style.display = (status ? 'block' : 'none'); } } const redirectTo = (response, url) => { if (envIsNode) { response.statusCode = 302; response.setHeader('location', url); response.end(); } if (envIsBrowser) { navigateClientPage('#' + (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()) { if (key in params) { params[key] = [].concat(params[key], value); } else { 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 makeCookieString = (cookieData) => { let cookieString = ''; for (const cookieName in cookieData) { cookieString += `; ${cookieName}=${cookieData[cookieName].value}`; } return cookieString.slice(2); } const parseCookieString = (cookieString) => { const cookieData = {}; if (!cookieString) { return cookieData; } for (const cookie of cookieString.split('; ')) { const [key, ...rest] = cookie.split('='); cookieData[key] = { value: rest.join('=') }; } if (allOpts.server.metaCookie) { const metaData = parseMetaCookie(cookieData[allOpts.server.metaCookie]?.value); for (const cookieName in cookieData) { cookieData[cookieName] = { ...cookieData[cookieName], ...metaData[cookieName] }; } } return cookieData; }; const setCookie = (cookieData, response, cookie, cookieValue, cookieFlags) => { const cookieSet = []; const setCookie = (cookie, cookieValue, cookieFlags) => { // TODO implement setCookie with standard raw format (current) and optional javascript object format let cookieString, cookieName; if (cookieValue === undefined) { cookieString = cookie; [cookieName, ...cookieValue] = cookieString.split('; ')[0].split('='); cookieValue = cookieValue.join('='); } else { cookieName = cookie; } // Expires is deprecated, but old browsers don't support Max-Age // (https://mrcoles.com/blog/cookies-max-age-vs-expires) // so, we set Expires when it is missing but Max-Age is present let expires = false; let maxAge; for (let flag of cookieString.split('; ').slice(1)) { flag = flag.toLowerCase(); if (!expires && flag.startsWith('max-age=')) { maxAge = flag.split('=')[1]; } else if (flag.startsWith('expires=')) { expires = true; } } if (!expires && maxAge) { cookieString += `; expires=${(new Date(Date.now() + (maxAge * 1000))).toUTCString()}`; } if (envIsNode) { cookieSet.push(cookieString); } else if (envIsBrowser) { clientCookieApi(cookieString); } // TODO update cookie flags inside cookieData, this just updates the value // because of this (?) also the value of the metaCookie is not updated in the request cookieData[cookieName] ||= {}; cookieData[cookieName].value = cookieValue; cookieString = makeCookieString(cookieData); return cookieString; }; const result = setCookie(cookie, cookieValue, cookieFlags); if (allOpts.server.metaCookie) { const [cookieBody, ...cookieFlags] = cookie.split('; '); const cookieName = cookieBody.split('=')[0]; const metaData = parseMetaCookie(cookieData[allOpts.server.metaCookie]?.value); if (cookieFlags.length) { metaData[cookieName] = parseMetaCookie(`${cookieName}&${cookieFlags.join('&')}`)[cookieName]; } else { delete metaData[cookieName]; } setCookie(`${allOpts.server.metaCookie}=${makeMetaCookie(metaData)}; max-age=${cookieMaxAge2Years}`); } if (envIsNode) { response.setHeader('set-cookie', cookieSet); } return result; }; // TODO handle renewal of all cookies at the same time if no name specified? const renewCookie = (getCookie, setCookie, cookieName) => { const metaData = parseMetaCookie(getCookie(allOpts.server.metaCookie)); const cookieFlags = makeMetaCookie({ [cookieName]: metaData[cookieName] }).split('|')[0].slice(cookieName.length).replaceAll('&', '; '); setCookie(`${cookieName}=${getCookie(cookieName)}${cookieFlags}`); setCookie(`${allOpts.server.metaCookie}=${makeMetaCookie(metaData)}; max-age=${cookieMaxAge2Years}`); }; // below we use pipe ('|') to split cookies and amperstand ('&') for fields, // no problem since as of 2024 no standard cookie flag has that in the body const makeMetaCookie = (metaData) => { let metaString = ''; metaData[allOpts.server.metaCookie] ||= {}; metaData[allOpts.server.metaCookie]['set-date'] = (new Date()).toUTCString(); for (const [name, flags] of Object.entries(metaData)) { metaString += `|${name}&`; for (const [key, value] of Object.entries(flags)) { // by standard, boolean cookie flags are named without any value if true, omitted if false metaString += (value === true ? `${key}&` : `${key}=${value}&`); } metaString = metaString.slice(0, -1); } return metaString.slice(1); }; const parseMetaCookie = (metaString) => { const metaCookie = {}; if (!metaString) { return metaCookie; } for (const cookie of metaString.split('|')) { const [name, ...fields] = cookie.split('&'); metaCookie[name] = {}; for (const field of fields) { if (!field) { continue; } const [key, ...tokens] = field.split('='); metaCookie[name][key] = (tokens.join('=') || true); } } return metaCookie; }; // 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 parseclientLanguages = (request) => { if (envIsNode) { const languages = []; const languageTokens = request.headers['accept-language']?.split(','); if (languageTokens) { for (const language of languageTokens) { languages.push(language.split(';')[0].trim()); } } return languages; } else if (envIsBrowser) { return (navigator.languages || [navigator.language /* || navigator.userLanguage */]); } }; const exportObj = { envIsNode, envIsBrowser, setup, makeHtmlStyleFragment, makeHtmlScriptFragment }; 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; } })();