SpaccDotWeb/SpaccDotWeb.Server.js

512 lines
20 KiB
JavaScript

/* 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) => `<!DOCTYPE html><html><head><!--
--><meta charset="utf-8"/><!--
--><meta name="viewport" content="width=device-width, initial-scale=1.0"/><!--
--><title>${(opts.pageTitler || allOpts.global.pageTitler)(title, opts, context)}</title><!--
-->${allOpts.global.linkStyles.map((path) => makeHtmlStyleFragment(path, opts.selfContained)).join('')}<!--
-->${allOpts.global.linkClientScripts.map((path) => makeHtmlScriptFragment(path, opts.selfContained)).join('')}<!--
--></head><body><!--
--><div id="transition"></div><!--
--><div id="app">${(opts.appPager || allOpts.global.appPager)(content, title, opts, context)}</div><!--
--></body></html>`;
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() : ''} };
</${'script'}>
${makeHtmlScriptFragment(path.basename(appFilePath), selfContained)}
`, null, { selfContained, context }, context));
return htmlFilePath;
};
const makeHtmlStyleFragment = (path, getContent) => {
const data = getFilePathContent(path, getContent);
return (data[1] ? `<style>${data[1]}</style>` : `<link rel="stylesheet" href="${data[0]}"/>`);
};
const makeHtmlScriptFragment = (path, getContent) => {
const data = getFilePathContent(path, getContent);
return `<${'script'}${data[1] ? `>${data[1]}` : ` src="${data[0]}">`}</${'script'}>`;
};
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;
}
})();