diff --git a/Example.Server.html b/Example.Server.html
index 1b367a8..f5ec366 100644
--- a/Example.Server.html
+++ b/Example.Server.html
@@ -1,16 +1,16 @@
Example
-
+
\ No newline at end of file
diff --git a/Example.Server.js b/Example.Server.js
index 8540bc3..1fe8489 100644
--- a/Example.Server.js
+++ b/Example.Server.js
@@ -1,16 +1,20 @@
-const SpaccDotWebServer = require('./SpaccDotWeb.Server.js')({
+const SpaccDotWebServer = require('./SpaccDotWeb.Server.js');
+const server = SpaccDotWebServer.setup({
appName: 'Example',
// staticPrefix: '/static/',
// staticFiles: [],
linkStyles: [ 'Example.css' ],
// linkScripts: [],
- // htmlPager: htmlPager(content) => `...`,
+ // pageTitler: pageTitler(title) => `...`,
+ // appPager: appPager(content, title) => `...`,
+ // htmlPager: htmlPager(content, title) => `...`,
});
-if (process && process.argv[2] === 'html') {
- SpaccDotWebServer.writeStaticHtml(__filename);
+if (SpaccDotWebServer.envIsNode && process.argv[2] === 'html') {
+ server.writeStaticHtml();
+ console.log('Dumped Static HTML!');
} else {
- SpaccDotWebServer.initServer({
+ server.initServer({
// defaultResponse: { code: 500, headers: {} },
// endpointsFalltrough: false,
// port: 3000,
@@ -19,7 +23,7 @@ if (process && process.argv[2] === 'html') {
// appElement: 'div#app',
// transitionElement: 'div#transition',
endpoints: [
- [ (ctx) => (['GET', 'POST'].includes(ctx.request.method) && ctx.request.url.toLowerCase().startsWith('/main/')), (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}`);
@@ -32,13 +36,13 @@ if (process && process.argv[2] === 'html') {
${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()}
+ ${ctx.getCookie() || '[None]'}
`;
- ctx.renderPage(content);
+ ctx.renderPage(content, 'Test');
// return { code: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: content }
} ],
@@ -46,4 +50,5 @@ if (process && process.argv[2] === 'html') {
// [ (ctx) => (ctx.request.method === 'GET'), (ctx) => ({ code: 302, headers: { location: '/main/' } }) ],
],
});
+ console.log('Running Server...');
};
diff --git a/Example.css b/Example.css
index 6af4cf9..90be467 100644
--- a/Example.css
+++ b/Example.css
@@ -4,8 +4,7 @@ body {
}
h2 {
- position: relative;
- top: 3em;
+ width: max-content;
rotate: 15deg;
- color: purple;
+ color: DeepPink;
}
diff --git a/SpaccDotWeb.Server.js b/SpaccDotWeb.Server.js
index bec0381..92d28cd 100644
--- a/SpaccDotWeb.Server.js
+++ b/SpaccDotWeb.Server.js
@@ -1,266 +1,294 @@
+/* TODO:
+ * built-in logging
+ * other things listed in the file
+ */
(() => {
- const envIsNode = (typeof module === 'object' && typeof module.exports === 'object');
- const envIsBrowser = (typeof window !== 'undefined' && typeof window.document !== 'undefined');
- let fs, path, mime, multipart;
- const allOpts = {};
- const main = (globalOptions={}) => {
- allOpts.global = globalOptions;
- allOpts.global.staticPrefix ||= '/static/';
- allOpts.global.staticFiles ||= [];
- allOpts.global.linkStyles ||= [];
- allOpts.global.linkScripts ||= [];
- for (const item of [...allOpts.global.linkStyles, ...allOpts.global.linkScripts]) {
- const itemLow = item.toLowerCase();
- if (!(itemLow.startsWith('http://') || itemLow.startsWith('https://') || itemLow.startsWith('/'))) {
- allOpts.global.staticFiles.push(item);
- };
+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;
+
+const setup = (globalOptions={}) => {
+ allOpts.global = globalOptions;
+ allOpts.global.staticPrefix ||= '/static/';
+ allOpts.global.staticFiles ||= [];
+ allOpts.global.linkStyles ||= [];
+ allOpts.global.linkScripts ||= [];
+ for (const item of [...allOpts.global.linkStyles, ...allOpts.global.linkScripts]) {
+ const itemLow = item.toLowerCase();
+ if (!(itemLow.startsWith('http://') || itemLow.startsWith('https://') || itemLow.startsWith('/'))) {
+ allOpts.global.staticFiles.push(item);
};
- allOpts.global.htmlPager ||= (content) => `${allOpts.global.linkStyles.map((item) => {
- return ``;
- }).join('')}${content}
`;
- const result = {};
- result.initServer = (serverOptions={}) => initServer(serverOptions);
- if (envIsNode) {
- result.writeStaticHtml = (mainScriptPath) => writeStaticHtml(mainScriptPath);
- };
- return result;
};
+ 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)}${allOpts.global.linkStyles.map((item) => {
+ return ``;
+ }).join('')}${allOpts.global.appPager(content, title)}
`;
+ 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;
- if (envIsNode) {
- allOpts.server.port ||= 3000;
- 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';
- const navigatePage = () => handleRequest({ url: window.location.hash.slice(1), method: 'GET' });
- window.addEventListener('hashchange', () => {
- window.location.hash ||= '/';
- navigatePage();
- });
+const initServer = (serverOptions) => {
+ allOpts.server = serverOptions;
+ allOpts.server.defaultResponse ||= { code: 500, headers: {} };
+ allOpts.server.endpointsFalltrough ||= false;
+ if (envIsNode) {
+ allOpts.server.port ||= 3000;
+ 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';
+ const navigatePage = () => handleRequest({ url: window.location.hash.slice(1), method: 'GET' });
+ window.addEventListener('hashchange', () => {
+ window.location.hash ||= '/';
navigatePage();
- };
+ });
+ navigatePage();
};
+};
- const writeStaticHtml = (mainScriptPath) => {
- fs.writeFileSync((mainScriptPath.split('.').slice(0, -1).join('.') + '.html'), allOpts.global.htmlPager(`
-
-
-
- `));
+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 handleRequest = async (request, response={}) => {
+ // build request context and handle special tasks
+ let result = allOpts.server.defaultResponse;
+ const context = {
+ 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?
+ getCookie: (cookie) => getCookie(request, cookie),
+ setCookie: (cookie) => setCookie(response, cookie),
+ renderPage: (content, title) => renderPage(response, content, title),
+ redirectTo: (url) => redirectTo(response, url),
};
-
- const handleRequest = async (request, response={}) => {
- // build request context and handle special tasks
- let result = allOpts.server.defaultResponse;
- const context = {
- request,
- response,
- urlParameters: (new URLSearchParams(request.url.split('?')[1]?.join('?'))),
- bodyParameters: (request.method === 'POST' && await parseBodyParams(request)), // TODO which other methods need body?
- getCookie: (cookie) => getCookie(request, cookie),
- setCookie: (cookie) => setCookie(response, cookie),
- renderPage: (content) => renderPage(response, content),
- redirectTo: (url) => redirectTo(response, url),
- };
- // 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 = (__dirname + 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 };
- };
+ // client transitions
+ if (envIsBrowser && document.querySelector(allOpts.server.transitionElement)) {
+ document.querySelector(allOpts.server.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)) {
+ result = { code: 200, headers: { 'content-type': mime.lookup(filePath) }, body: fs.readFileSync(filePath) };
} else {
- // handle custom endpoints
- for (const [check, procedure] of allOpts.server.endpoints) {
- if (check(context)) {
- result = await procedure(context);
- if (!allOpts.server.endpointsFalltrough) {
- break;
- };
- };
- };
+ result = { code: 404 };
};
- // 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) => {
- // 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));
- };
- if (envIsBrowser) {
- document.querySelector(allOpts.server.appElement).innerHTML = content;
- for (const srcElem of document.querySelectorAll(`[src^="${allOpts.global.staticPrefix}"]`)) {
- srcElem.src = resFilesData[srcElem.getAttribute('src')];
- };
- for (const linkElem of document.querySelectorAll(`link[rel="stylesheet"][href^="${allOpts.global.staticPrefix}"]`)) {
- linkElem.href = resFilesData[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();
- 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,
- });
- };
- };
- };
- };
-
- const redirectTo = (response, url) => {
- if (envIsNode) {
- response.statusCode = 302;
- response.setHeader('location', url);
- response.end();
- };
- if (envIsBrowser) {
- 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()) {
- 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 getCookie = (request, name) => {
- let cookies;
- if (envIsNode) {
- cookies = request.headers?.cookie;
- };
- if (envIsBrowser) {
- cookies = clientCookieApi();
- };
- if (name) {
- // get a specific cookie
- for (const cookie of (cookies?.split(';') || [])) {
- const [key, ...rest] = cookie.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) {
- clientCookieApi(cookie);
- };
- };
-
- // 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?
- if (set) {
- let api = sessionStorage;
- const tokens = set.split(';');
- const [key, ...rest] = tokens[0].split('=');
- for (let token of tokens) {
- token = token.trim();
- if (['expires', 'max-age'].includes(token.split('=')[0].toLowerCase())) {
- api = localStorage;
+ } else {
+ // handle custom endpoints
+ for (const [check, procedure] of allOpts.server.endpoints) {
+ if (await check(context)) {
+ result = await procedure(context);
+ if (!allOpts.server.endpointsFalltrough) {
break;
};
};
- api.setItem(`${gid}/${key}`, rest.join('='));
- } 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);
};
- }));
+ };
+ // 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) => {
+ // TODO titles and things
+ // TODO status code could need to be different in different situations and so should be set accordingly?
if (envIsNode) {
- fs = require('fs');
- path = require('path');
- mime = require('mime-types');
- multipart = require('parse-multipart-data');
- module.exports = main;
+ response.setHeader('content-type', 'text/html; charset=utf-8');
+ response.end(allOpts.global.htmlPager(content, title));
};
if (envIsBrowser) {
- window.SpaccDotWebServer = main;
+ document.title = allOpts.global.pageTitler(title);
+ document.querySelector(allOpts.server.appElement).innerHTML = allOpts.global.appPager(content, title);
+ for (const srcElem of document.querySelectorAll(`[src^="${allOpts.global.staticPrefix}"]`)) {
+ srcElem.src = window.SpaccDotWebServer.resFilesData[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)];
+ };
+ 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();
+ 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,
+ });
+ };
+ };
+ if (document.querySelector(allOpts.server.transitionElement)) {
+ document.querySelector(allOpts.server.transitionElement).style.display = 'none';
+ };
};
+};
+
+const redirectTo = (response, url) => {
+ if (envIsNode) {
+ response.statusCode = 302;
+ response.setHeader('location', url);
+ response.end();
+ };
+ if (envIsBrowser) {
+ 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()) {
+ 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 getCookie = (request, name) => {
+ let cookies;
+ if (envIsNode) {
+ cookies = (request.headers?.cookie || '');
+ };
+ if (envIsBrowser) {
+ cookies = clientCookieApi();
+ };
+ if (name) {
+ // get a specific cookie
+ for (const cookie of (cookies?.split(';') || [])) {
+ const [key, ...rest] = cookie.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) {
+ clientCookieApi(cookie);
+ };
+};
+
+// 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 exportObj = { envIsNode, envIsBrowser, setup };
+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;
+};
+
})();
diff --git a/package-lock.json b/package-lock.json
index cec180f..3ac7123 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "SpaccDotWeb",
- "version": "indev",
+ "version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "SpaccDotWeb",
- "version": "indev",
+ "version": "0.2.1",
"dependencies": {
"mime-types": "^2.1.35",
"parse-multipart-data": "^1.5.0"