diff --git a/Example.Server.html b/Example.Server.html
new file mode 100644
index 0000000..1b367a8
--- /dev/null
+++ b/Example.Server.html
@@ -0,0 +1,16 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Example.Server.js b/Example.Server.js
new file mode 100644
index 0000000..8540bc3
--- /dev/null
+++ b/Example.Server.js
@@ -0,0 +1,49 @@
+const SpaccDotWebServer = require('./SpaccDotWeb.Server.js')({
+ appName: 'Example',
+ // staticPrefix: '/static/',
+ // staticFiles: [],
+ linkStyles: [ 'Example.css' ],
+ // linkScripts: [],
+ // htmlPager: htmlPager(content) => `...`,
+});
+
+if (process && process.argv[2] === 'html') {
+ SpaccDotWebServer.writeStaticHtml(__filename);
+} else {
+ SpaccDotWebServer.initServer({
+ // defaultResponse: { code: 500, headers: {} },
+ // endpointsFalltrough: false,
+ // port: 3000,
+ // address: '127.0.0.1',
+ // maxBodyUploadSize: null,
+ // appElement: 'div#app',
+ // transitionElement: 'div#transition',
+ endpoints: [
+ [ (ctx) => (['GET', 'POST'].includes(ctx.request.method) && ctx.request.url.toLowerCase().startsWith('/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=`);
+ };
+ };
+ 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()}
+
+ `;
+ ctx.renderPage(content);
+ // 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/' } }) ],
+ ],
+ });
+};
diff --git a/Example.css b/Example.css
new file mode 100644
index 0000000..6af4cf9
--- /dev/null
+++ b/Example.css
@@ -0,0 +1,11 @@
+body {
+ background-color: lightgray;
+ color: black;
+}
+
+h2 {
+ position: relative;
+ top: 3em;
+ rotate: 15deg;
+ color: purple;
+}
diff --git a/SpaccDotWeb.Alt.js b/SpaccDotWeb.Alt.js
index 681909a..3fddc79 100644
--- a/SpaccDotWeb.Alt.js
+++ b/SpaccDotWeb.Alt.js
@@ -21,6 +21,7 @@
document.body.appendChild(scriptElem);
});
}
+ // .RequireScripts = (...) => {}
SpaccDotWeb.ShowModal = async (params) => {
// TODO: delete dialogs from DOM after use (garbage collect)?
diff --git a/SpaccDotWeb.Server.js b/SpaccDotWeb.Server.js
new file mode 100644
index 0000000..bec0381
--- /dev/null
+++ b/SpaccDotWeb.Server.js
@@ -0,0 +1,266 @@
+(() => {
+ 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);
+ };
+ };
+ 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;
+ };
+
+ 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 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 };
+ };
+ } else {
+ // handle custom endpoints
+ for (const [check, procedure] of allOpts.server.endpoints) {
+ if (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) => {
+ // 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;
+ 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);
+ };
+ }));
+
+ if (envIsNode) {
+ fs = require('fs');
+ path = require('path');
+ mime = require('mime-types');
+ multipart = require('parse-multipart-data');
+ module.exports = main;
+ };
+ if (envIsBrowser) {
+ window.SpaccDotWebServer = main;
+ };
+})();
diff --git a/package-lock.json b/package-lock.json
index a5acefe..cec180f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,10 @@
"": {
"name": "SpaccDotWeb",
"version": "indev",
+ "dependencies": {
+ "mime-types": "^2.1.35",
+ "parse-multipart-data": "^1.5.0"
+ },
"devDependencies": {
"@babel/cli": "^7.23.0",
"@babel/core": "^7.23.0",
@@ -2573,7 +2577,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "dev": true,
"engines": {
"node": ">= 0.6"
}
@@ -2582,7 +2585,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "dev": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -2639,6 +2641,11 @@
"wrappy": "1"
}
},
+ "node_modules/parse-multipart-data": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/parse-multipart-data/-/parse-multipart-data-1.5.0.tgz",
+ "integrity": "sha512-ck5zaMF0ydjGfejNMnlo5YU2oJ+pT+80Jb1y4ybanT27j+zbVP/jkYmCrUGsEln0Ox/hZmuvgy8Ra7AxbXP2Mw=="
+ },
"node_modules/parse5": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
diff --git a/package.json b/package.json
index 65c21e4..fea5ac0 100644
--- a/package.json
+++ b/package.json
@@ -13,5 +13,9 @@
"dialog-polyfill": "^0.5.6",
"jsdom": "^22.1.0",
"uglify-js": "^3.17.4"
+ },
+ "dependencies": {
+ "mime-types": "^2.1.35",
+ "parse-multipart-data": "^1.5.0"
}
}