diff --git a/Example.Server.js b/Example.Server.js index cd92b64..95bd3ce 100755 --- a/Example.Server.js +++ b/Example.Server.js @@ -6,9 +6,9 @@ const server = SpaccDotWebServer.setup({ // staticFiles: [], linkStyles: [ 'Example.css' ], // linkScripts: [], - // pageTitler: (title) => `...`, - // appPager: (content, title) => `...`, - // htmlPager: (content, title) => `...`, + // pageTitler: (title, opts={}) => `...`, + // appPager: (content, title, opts={}) => `...`, + // htmlPager: (content, title, opts={}) => `...`, }); if (SpaccDotWebServer.envIsNode && ['dump', 'html'].includes(process.argv[2])) { @@ -19,12 +19,13 @@ if (SpaccDotWebServer.envIsNode && ['dump', 'html'].includes(process.argv[2])) { // defaultResponse: { code: 500, headers: {} }, // endpointsFalltrough: false, // port: 3000, - // address: '127.0.0.1', + address: '0.0.0.0', // maxBodyUploadSize: null, // handleHttpHead: true, // appElement: 'div#app', // transitionElement: 'div#transition', - // cookieInUrl: 'spaccdotweb-cookie', + // metaCookie: 'spaccdotweb-meta', + // cookieInUrl: 'spaccdotweb-cookie', // not (yet) implemented // endpoints are defined by a discriminator and an action endpoints: [ @@ -46,9 +47,8 @@ if (SpaccDotWebServer.envIsNode && ['dump', 'html'].includes(process.argv[2])) { } // a short sleep so that we can test client transitions - await (new Promise(r => setTimeout(r, 1500))); + await (new Promise(resolve => setTimeout(resolve, 1500))); } - // TODO: setCookie should update the current cookie context, so that following getCookie calls return updated data const content = `

Test

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

diff --git a/SpaccDotWeb.Server.js b/SpaccDotWeb.Server.js index 5ba618c..857e2c1 100644 --- a/SpaccDotWeb.Server.js +++ b/SpaccDotWeb.Server.js @@ -1,9 +1,12 @@ /* TODO: * built-in logging * configure to embed linked styles and scripts into the HTML output, or just link to file + * handle linkScripts to insert in the HTML, like linkStyles + * differentiate between client-only, served-only, and both-mode scripts? * relative URL root * utility functions for rewriting url query parameters? - * fix hash navigation to prevent no-going-back issue + * fix hash navigation to prevent no-going-back issue (possible?) + * finish polishing the cookie API * other things listed in the file */ (() => { @@ -13,6 +16,11 @@ const envIsBrowser = (typeof window !== 'undefined' && typeof window.document != 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'; @@ -26,18 +34,18 @@ const setup = (globalOptions={}) => { 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.pageTitler ||= (title, opts={}) => `${title || ''}${title && allOpts.global.appName ? ' — ' : ''}${allOpts.global.appName || ''}`, + allOpts.global.appPager ||= (content, title, opts={}) => content, allOpts.global.htmlPager ||= (content, title, opts={}) => `${(opts.pageTitler || allOpts.global.pageTitler)(title)}${(opts.pageTitler || allOpts.global.pageTitler)(title, opts)}${allOpts.global.linkStyles.map((item) => { return ``; }).join('')}
${(opts.appPager || allOpts.global.appPager)(content, title)}
${(opts.appPager || allOpts.global.appPager)(content, title, opts)}
`; const result = {}; result.initServer = (serverOptions={}) => initServer(serverOptions); @@ -51,6 +59,7 @@ 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'; @@ -101,20 +110,24 @@ const handleRequest = async (request, response={}) => { const context = { 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? - getCookie: (cookie) => getCookie(request, cookie), - setCookie: (cookie) => setCookie(response, cookie), //storageApi: (key,value, opts) => storageApi(key, value, opts), - renderPage: (content, title, opts) => renderPage(response, content, title, opts), redirectTo: (url) => redirectTo(response, url), - clientLanguages: parseclientLanguages(request), }; + context.renderPage = (content, title, opts) => renderPage(response, content, title, { ...opts, 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); - // TODO check if this is respected even when using renderPage? const handlingHttpHead = (allOpts.server.handleHttpHead && request.method === 'HEAD') if (handlingHttpHead) { request.method = 'GET'; @@ -174,11 +187,11 @@ const renderPage = (response, content, title, opts={}) => { // 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)); + response.end((opts.htmlPager || allOpts.global.htmlPager)(content, title, opts)); } if (envIsBrowser) { - document.title = (opts.pageTitler || allOpts.global.pageTitler)(title); - document.querySelector(allOpts.server.appElement).innerHTML = ((opts.appPager || allOpts.global.appPager)(content, title)); + document.title = (opts.pageTitler || allOpts.global.pageTitler)(title, opts); + document.querySelector(allOpts.server.appElement).innerHTML = ((opts.appPager || allOpts.global.appPager)(content, title, opts)); for (const srcElem of document.querySelectorAll(`[src^="${allOpts.global.staticPrefix}"]`)) { srcElem.src = window.SpaccDotWebServer.staticFilesData[srcElem.getAttribute('src')]; } @@ -264,35 +277,133 @@ const parseBodyParams = async (request) => { } }; -const getCookie = (request, name) => { - let cookies; - if (envIsNode) { - cookies = (request.headers?.cookie || ''); - } else if (envIsBrowser) { - cookies = clientCookieApi(); +const makeCookieString = (cookieData) => { + let cookieString = ''; + for (const cookieName in cookieData) { + cookieString += `; ${cookieName}=${cookieData[cookieName].value}`; } - if (name) { - // get a specific cookie - for (const cookie of (cookies?.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('='); - } + 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] }; } - } else { - // get all cookies - return cookies; } + return cookieData; }; -const setCookie = (response, cookie) => { - if (envIsNode) { - response.setHeader('Set-Cookie', cookie); - // TODO update current cookie list in existing request to reflect new assignment - } else if (envIsBrowser) { - clientCookieApi(cookie); +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:///)