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 };