mirror of
				https://gitlab.com/SpaccInc/SpaccDotWeb.git
				synced 2025-06-05 21:29:12 +02:00 
			
		
		
		
	[Server] Add client language data, raw URL strings in ctx, HEAD method, handle arrays in HTML form
This commit is contained in:
		| @@ -21,8 +21,10 @@ if (SpaccDotWebServer.envIsNode && ['dump', 'html'].includes(process.argv[2])) { | |||||||
| 		// port: 3000, | 		// port: 3000, | ||||||
| 		// address: '127.0.0.1', | 		// address: '127.0.0.1', | ||||||
| 		// maxBodyUploadSize: null, | 		// maxBodyUploadSize: null, | ||||||
|  | 		// handleHttpHead: true, | ||||||
| 		// appElement: 'div#app', | 		// appElement: 'div#app', | ||||||
| 		// transitionElement: 'div#transition', | 		// transitionElement: 'div#transition', | ||||||
|  | 		// cookieInUrl: 'spaccdotweb-cookie', | ||||||
|  |  | ||||||
| 		// endpoints are defined by a discriminator and an action | 		// endpoints are defined by a discriminator and an action | ||||||
| 		endpoints: [ | 		endpoints: [ | ||||||
|   | |||||||
| @@ -1,5 +1,9 @@ | |||||||
| /* TODO: | /* TODO: | ||||||
|  * built-in logging |  * built-in logging | ||||||
|  |  * configure to embed linked styles and scripts into the HTML output, or just link to file | ||||||
|  |  * relative URL root | ||||||
|  |  * utility functions for rewriting url query parameters? | ||||||
|  |  * fix hash navigation to prevent no-going-back issue | ||||||
|  * other things listed in the file |  * other things listed in the file | ||||||
|  */ |  */ | ||||||
| (() => { | (() => { | ||||||
| @@ -11,6 +15,7 @@ let fs, path, mime, multipart; | |||||||
|  |  | ||||||
| const setup = (globalOptions={}) => { | const setup = (globalOptions={}) => { | ||||||
| 	allOpts.global = globalOptions; | 	allOpts.global = globalOptions; | ||||||
|  | 	//allOpts.global.appName ||= 'Untitled SpaccDotWeb App'; | ||||||
| 	allOpts.global.staticPrefix ||= '/static/'; | 	allOpts.global.staticPrefix ||= '/static/'; | ||||||
| 	allOpts.global.staticFiles ||= []; | 	allOpts.global.staticFiles ||= []; | ||||||
| 	allOpts.global.linkStyles ||= []; | 	allOpts.global.linkStyles ||= []; | ||||||
| @@ -50,6 +55,7 @@ const initServer = (serverOptions) => { | |||||||
| 		allOpts.server.port ||= 3000; | 		allOpts.server.port ||= 3000; | ||||||
| 		allOpts.server.address ||= '127.0.0.1'; | 		allOpts.server.address ||= '127.0.0.1'; | ||||||
| 		allOpts.server.maxBodyUploadSize = (parseInt(allOpts.server.maxBodyUploadSize) || undefined); | 		allOpts.server.maxBodyUploadSize = (parseInt(allOpts.server.maxBodyUploadSize) || undefined); | ||||||
|  | 		allOpts.server.handleHttpHead ||= true; | ||||||
| 		require('http').createServer(handleRequest).listen(allOpts.server.port, allOpts.server.address); | 		require('http').createServer(handleRequest).listen(allOpts.server.port, allOpts.server.address); | ||||||
| 	} | 	} | ||||||
| 	if (envIsBrowser) { | 	if (envIsBrowser) { | ||||||
| @@ -71,8 +77,14 @@ const writeStaticHtml = () => { | |||||||
| 	const fileName = (process.mainModule.filename.split('.').slice(0, -1).join('.') + '.html'); | 	const fileName = (process.mainModule.filename.split('.').slice(0, -1).join('.') + '.html'); | ||||||
| 	fs.writeFileSync(fileName, allOpts.global.htmlPager(` | 	fs.writeFileSync(fileName, allOpts.global.htmlPager(` | ||||||
| 		<script src="./${path.basename(__filename)}"></script> | 		<script src="./${path.basename(__filename)}"></script> | ||||||
|  | 		<script src="./SpaccDotWeb.Alt.js"></script> | ||||||
| 		<script> | 		<script> | ||||||
| 			window.require = () => window.SpaccDotWebServer; | 			window.require = () => { | ||||||
|  | 				window.require = async (src, type) => { | ||||||
|  | 					await SpaccDotWeb.RequireScript((src.startsWith('./') ? src : ('node_modules/' + src)), type); | ||||||
|  | 				}; | ||||||
|  | 				return window.SpaccDotWebServer; | ||||||
|  | 			}; | ||||||
| 			window.SpaccDotWebServer.staticFilesData = { ${allOpts.global.staticFiles.map((file) => { | 			window.SpaccDotWebServer.staticFilesData = { ${allOpts.global.staticFiles.map((file) => { | ||||||
| 				const filePath = (process.mainModule.filename.split(path.sep).slice(0, -1).join(path.sep) + path.sep + file); | 				const filePath = (process.mainModule.filename.split(path.sep).slice(0, -1).join(path.sep) + path.sep + file); | ||||||
| 				return `"${file}":"data:${mime.lookup(filePath)};base64,${fs.readFileSync(filePath).toString('base64')}"`; | 				return `"${file}":"data:${mime.lookup(filePath)};base64,${fs.readFileSync(filePath).toString('base64')}"`; | ||||||
| @@ -89,22 +101,23 @@ const handleRequest = async (request, response={}) => { | |||||||
| 	const context = { | 	const context = { | ||||||
| 		request, | 		request, | ||||||
| 		response, | 		response, | ||||||
| 		urlSections: request.url.slice(1).toLowerCase().split('?')[0].split('/'), | 		urlPath: request.url.slice(1).toLowerCase().split('?')[0], | ||||||
| 		urlParameters: Object.fromEntries(new URLSearchParams(request.url.split('?')?.slice(1)?.join('?'))), | 		urlQuery: request.url.split('?')?.slice(1)?.join('?'), | ||||||
| 		bodyParameters: (['POST', 'PUT', 'PATCH'].includes(request.method) && await parseBodyParams(request)), // TODO which other methods need body? | 		bodyParameters: (['POST', 'PUT', 'PATCH'].includes(request.method) && await parseBodyParams(request)), // TODO which other methods need body? | ||||||
| 		getCookie: (cookie) => getCookie(request, cookie), | 		getCookie: (cookie) => getCookie(request, cookie), | ||||||
| 		setCookie: (cookie) => setCookie(response, cookie), | 		setCookie: (cookie) => setCookie(response, cookie), | ||||||
| 		//storageApi: (key,value, opts) => storageApi(key, value, opts), | 		//storageApi: (key,value, opts) => storageApi(key, value, opts), | ||||||
| 		renderPage: (content, title, opts) => renderPage(response, content, title, opts), | 		renderPage: (content, title, opts) => renderPage(response, content, title, opts), | ||||||
| 		redirectTo: (url) => redirectTo(response, url), | 		redirectTo: (url) => redirectTo(response, url), | ||||||
|  | 		clientLanguages: parseclientLanguages(request), | ||||||
| 	}; | 	}; | ||||||
| 	// client transitions | 	context.urlSections = context.urlPath.split('/'); | ||||||
| 	if (envIsBrowser) { | 	context.urlParameters = Object.fromEntries(new URLSearchParams(context.urlQuery)); | ||||||
| 		const transitionElement = document.querySelector(allOpts.server.transitionElement); | 	setClientTransition(true); | ||||||
| 		if (transitionElement) { | 	// TODO check if this is respected even when using renderPage? | ||||||
| 			transitionElement.hidden = false; | 	const handlingHttpHead = (allOpts.server.handleHttpHead && request.method === 'HEAD') | ||||||
| 			transitionElement.style.display = 'block'; | 	if (handlingHttpHead) { | ||||||
| 		} | 		request.method = 'GET'; | ||||||
| 	} | 	} | ||||||
| 	// serve static files | 	// serve static files | ||||||
| 	if (envIsNode && request.method === 'GET' && request.url.toLowerCase().startsWith(allOpts.global.staticPrefix)) { | 	if (envIsNode && request.method === 'GET' && request.url.toLowerCase().startsWith(allOpts.global.staticPrefix)) { | ||||||
| @@ -132,7 +145,7 @@ const handleRequest = async (request, response={}) => { | |||||||
| 		for (const name in result.headers) { | 		for (const name in result.headers) { | ||||||
| 			response.setHeader(name, result.headers[name]); | 			response.setHeader(name, result.headers[name]); | ||||||
| 		} | 		} | ||||||
| 		response.end(result.body); | 		response.end(!handlingHttpHead && result.body); | ||||||
| 	} | 	} | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -158,7 +171,7 @@ const requestCheckFunction = (check, context) => { | |||||||
|  |  | ||||||
| const renderPage = (response, content, title, opts={}) => { | const renderPage = (response, content, title, opts={}) => { | ||||||
| 	// TODO titles and things | 	// TODO titles and things | ||||||
| 	// TODO status code could need to be different in different situations and so should be set accordingly? | 	// 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) { | 	if (envIsNode) { | ||||||
| 		response.setHeader('content-type', 'text/html; charset=utf-8'); | 		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)); | ||||||
| @@ -188,13 +201,17 @@ const renderPage = (response, content, title, opts={}) => { | |||||||
| 				}); | 				}); | ||||||
| 			}; | 			}; | ||||||
| 		} | 		} | ||||||
| 		const transitionElement = document.querySelector(allOpts.server.transitionElement); | 		setClientTransition(false); | ||||||
| 		if (transitionElement) { | 	}; | ||||||
| 			transitionElement.hidden = true; | }; | ||||||
| 			transitionElement.style.display = 'none'; |  | ||||||
|  | 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) => { | const redirectTo = (response, url) => { | ||||||
| 	if (envIsNode) { | 	if (envIsNode) { | ||||||
| @@ -217,15 +234,19 @@ const parseBodyParams = async (request) => { | |||||||
| 				if (request.body.length > allOpts.server.maxBodyUploadSize) { | 				if (request.body.length > allOpts.server.maxBodyUploadSize) { | ||||||
| 					request.connection?.destroy(); | 					request.connection?.destroy(); | ||||||
| 					// TODO handle this more gracefully? maybe an error callback or something? | 					// TODO handle this more gracefully? maybe an error callback or something? | ||||||
| 				}; | 				} | ||||||
| 			}); | 			}); | ||||||
| 			await new Promise((resolve) => request.on('end', () => resolve())); | 			await new Promise((resolve) => request.on('end', () => resolve())); | ||||||
| 		} | 		} | ||||||
| 		const [contentMime, contentEnc] = request.headers['content-type'].split(';'); | 		const [contentMime, contentEnc] = request.headers['content-type'].split(';'); | ||||||
| 		if (envIsNode && contentMime === 'application/x-www-form-urlencoded') { | 		if (envIsNode && contentMime === 'application/x-www-form-urlencoded') { | ||||||
| 			for (const [key, value] of (new URLSearchParams(request.body.toString())).entries()) { | 			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; | 					params[key] = value; | ||||||
| 				} | 				} | ||||||
|  | 			} | ||||||
| 		} else if (envIsNode && contentMime === 'multipart/form-data') { | 		} else if (envIsNode && contentMime === 'multipart/form-data') { | ||||||
| 			for (const param of multipart.parse(request.body, contentEnc.split('boundary=')[1])) { | 			for (const param of multipart.parse(request.body, contentEnc.split('boundary=')[1])) { | ||||||
| 				params[param.name] = (param.type && param.filename !== undefined ? param : param.data.toString()); | 				params[param.name] = (param.type && param.filename !== undefined ? param : param.data.toString()); | ||||||
| @@ -240,7 +261,7 @@ const parseBodyParams = async (request) => { | |||||||
| 	} catch (err) { | 	} catch (err) { | ||||||
| 		console.log(err); | 		console.log(err); | ||||||
| 		request.connection?.destroy(); | 		request.connection?.destroy(); | ||||||
| 	}; | 	} | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const getCookie = (request, name) => { | const getCookie = (request, name) => { | ||||||
| @@ -275,7 +296,9 @@ const setCookie = (response, cookie) => { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| // try to use the built-in cookie API, fallback to a Storage-based wrapper in case it fails (for example on file:///) | // 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 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? | 	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? | 	// also, TODO: what to do when no app name or any id is set? | ||||||
| 	if (set) { | 	if (set) { | ||||||
| @@ -308,6 +331,21 @@ const clientCookieApi = (envIsBrowser && (document.cookie || (!document.cookie & | |||||||
| 	} | 	} | ||||||
| })); | })); | ||||||
|  |  | ||||||
|  | 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 }; | const exportObj = { envIsNode, envIsBrowser, setup }; | ||||||
| if (envIsNode) { | if (envIsNode) { | ||||||
| 	fs = require('fs'); | 	fs = require('fs'); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user