[Server] add endpoint matching via strings, improve example, small improvements

This commit is contained in:
octospacc 2024-07-08 19:43:55 +02:00
parent 63dc2e648f
commit 495c7a8d2e
5 changed files with 151 additions and 95 deletions

5
.gitignore vendored
View File

@ -1,3 +1,4 @@
*.tmp *.tmp
Build/* /Build/*
node_modules/* /node_modules/*
/Example.Server.html

View File

@ -1,16 +0,0 @@
<!DOCTYPE html><html><head><!--
--><meta charset="utf-8"/><!--
--><meta name="viewport" content="width=device-width, initial-scale=1.0"/><!--
--><title>Example</title><!--
--><link rel="stylesheet" href="/static/Example.css"/><!--
--></head><body><!--
--><div id="transition"></div><!--
--><div id="app">
<script src="./SpaccDotWeb.Server.js"></script>
<script>
window.require = () => window.SpaccDotWebServer;
window.SpaccDotWebServer.resFilesData = { "Example.css": "data:text/css;base64,Ym9keSB7CgliYWNrZ3JvdW5kLWNvbG9yOiBsaWdodGdyYXk7Cgljb2xvcjogYmxhY2s7Cn0KCmgyIHsKCXdpZHRoOiBtYXgtY29udGVudDsKCXJvdGF0ZTogMTVkZWc7Cgljb2xvcjogRGVlcFBpbms7Cn0K" };
</script>
<script src="./Example.Server.js"></script>
</div><!--
--></body></html>

59
Example.Server.js Normal file → Executable file
View File

@ -1,3 +1,4 @@
#!/usr/bin/env node
const SpaccDotWebServer = require('./SpaccDotWeb.Server.js'); const SpaccDotWebServer = require('./SpaccDotWeb.Server.js');
const server = SpaccDotWebServer.setup({ const server = SpaccDotWebServer.setup({
appName: 'Example', appName: 'Example',
@ -5,16 +6,16 @@ const server = SpaccDotWebServer.setup({
// staticFiles: [], // staticFiles: [],
linkStyles: [ 'Example.css' ], linkStyles: [ 'Example.css' ],
// linkScripts: [], // linkScripts: [],
// pageTitler: pageTitler(title) => `...`, // pageTitler: (title) => `...`,
// appPager: appPager(content, title) => `...`, // appPager: (content, title) => `...`,
// htmlPager: htmlPager(content, title) => `...`, // htmlPager: (content, title) => `...`,
}); });
if (SpaccDotWebServer.envIsNode && process.argv[2] === 'html') { if (SpaccDotWebServer.envIsNode && ['dump', 'html'].includes(process.argv[2])) {
server.writeStaticHtml(); const fileName = server.writeStaticHtml();
console.log('Dumped Static HTML!'); console.log(`Dumped Static HTML to '${fileName}'!`);
} else { } else {
server.initServer({ const serverData = server.initServer({
// defaultResponse: { code: 500, headers: {} }, // defaultResponse: { code: 500, headers: {} },
// endpointsFalltrough: false, // endpointsFalltrough: false,
// port: 3000, // port: 3000,
@ -22,18 +23,32 @@ if (SpaccDotWebServer.envIsNode && process.argv[2] === 'html') {
// maxBodyUploadSize: null, // maxBodyUploadSize: null,
// appElement: 'div#app', // appElement: 'div#app',
// transitionElement: 'div#transition', // transitionElement: 'div#transition',
// endpoints are defined by a discriminator and an action
endpoints: [ 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(`<p>We're sorry but, to avoid disturbing the spirits, Testing is not available at 00:00. Please retry in just a minute.</p>`, '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.request.method === 'POST') {
if (ctx.bodyParameters?.add) { if (ctx.bodyParameters?.add) {
ctx.setCookie(`count=${parseInt(ctx.getCookie('count') || 0) + 1}`); ctx.setCookie(`count=${parseInt(ctx.getCookie('count') || 0) + 1}`);
} else if (ctx.bodyParameters?.reset) { } else if (ctx.bodyParameters?.reset) {
ctx.setCookie(`count=`); 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 = ` const content = `
<h2>Test</h2> <h2>Test</h2>
${ctx.request.method === 'POST' ? `<p>POST body parameters:</p><pre>${JSON.stringify(ctx.bodyParameters)}</pre>` : ''}
<p>This page was rendered at ${Date()}.</p> <p>This page was rendered at ${Date()}.</p>
<p>These were your cookies at time of request:</p> <p>These were your cookies at time of request:</p>
<pre>${ctx.getCookie() || '[None]'}</pre> <pre>${ctx.getCookie() || '[None]'}</pre>
@ -41,14 +56,28 @@ if (SpaccDotWebServer.envIsNode && process.argv[2] === 'html') {
<input type="submit" name="add" value="Add 1 to cookie"/> <input type="submit" name="add" value="Add 1 to cookie"/>
<input type="submit" name="reset" value="Reset cookies"/> <input type="submit" name="reset" value="Reset cookies"/>
</form> </form>
<p>Context data for this request:</p>
<pre>${JSON.stringify({
request: {
method: ctx.request.method,
},
urlSections: ctx.urlSections,
urlParameters: ctx.urlParameters,
bodyParameters: ctx.bodyParameters,
}, null, 2)}</pre>
`; `;
// the main content of a page can be rendered with the main template using:
ctx.renderPage(content, 'Test'); 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/') ], // redirects are easy
// [ (ctx) => (ctx.request.method === 'GET'), (ctx) => ({ code: 302, headers: { location: '/main/' } }) ], [ '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}>...`);
}
}; };

View File

@ -3,8 +3,21 @@ body {
color: black; color: black;
} }
h2 { div#app > h2 {
width: max-content; width: max-content;
rotate: 15deg; rotate: 15deg;
color: DeepPink; 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;
}

View File

@ -19,26 +19,26 @@ const setup = (globalOptions={}) => {
const itemLow = item.toLowerCase(); const itemLow = item.toLowerCase();
if (!(itemLow.startsWith('http://') || itemLow.startsWith('https://') || itemLow.startsWith('/'))) { if (!(itemLow.startsWith('http://') || itemLow.startsWith('https://') || itemLow.startsWith('/'))) {
allOpts.global.staticFiles.push(item); allOpts.global.staticFiles.push(item);
}; }
}; }
allOpts.global.pageTitler ||= (title) => `${title || ''}${title && allOpts.global.appName ? ' — ' : ''}${allOpts.global.appName || ''}`, allOpts.global.pageTitler ||= (title) => `${title || ''}${title && allOpts.global.appName ? ' — ' : ''}${allOpts.global.appName || ''}`,
allOpts.global.appPager ||= (content, title) => content, allOpts.global.appPager ||= (content, title) => content,
allOpts.global.htmlPager ||= (content, title) => `<!DOCTYPE html><html><head><!-- allOpts.global.htmlPager ||= (content, title, opts={}) => `<!DOCTYPE html><html><head><!--
--><meta charset="utf-8"/><!-- --><meta charset="utf-8"/><!--
--><meta name="viewport" content="width=device-width, initial-scale=1.0"/><!-- --><meta name="viewport" content="width=device-width, initial-scale=1.0"/><!--
--><title>${allOpts.global.pageTitler(title)}</title><!-- --><title>${(opts.pageTitler || allOpts.global.pageTitler)(title)}</title><!--
-->${allOpts.global.linkStyles.map((item) => { -->${allOpts.global.linkStyles.map((item) => {
return `<link rel="stylesheet" href="${allOpts.global.staticFiles.includes(item) ? (allOpts.global.staticPrefix + item) : item}"/>`; return `<link rel="stylesheet" href="${allOpts.global.staticFiles.includes(item) ? (allOpts.global.staticPrefix + item) : item}"/>`;
}).join('')}<!-- }).join('')}<!--
--></head><body><!-- --></head><body><!--
--><div id="transition"></div><!-- --><div id="transition"></div><!--
--><div id="app">${allOpts.global.appPager(content, title)}</div><!-- --><div id="app">${(opts.appPager || allOpts.global.appPager)(content, title)}</div><!--
--></body></html>`; --></body></html>`;
const result = {}; const result = {};
result.initServer = (serverOptions={}) => initServer(serverOptions); result.initServer = (serverOptions={}) => initServer(serverOptions);
if (envIsNode) { if (envIsNode) {
result.writeStaticHtml = writeStaticHtml; result.writeStaticHtml = writeStaticHtml;
}; }
return result; return result;
}; };
@ -51,7 +51,7 @@ const initServer = (serverOptions) => {
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);
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) {
allOpts.server.appElement ||= 'div#app'; allOpts.server.appElement ||= 'div#app';
allOpts.server.transitionElement ||= 'div#transition'; allOpts.server.transitionElement ||= 'div#transition';
@ -61,23 +61,26 @@ const initServer = (serverOptions) => {
navigatePage(); navigatePage();
}); });
navigatePage(); navigatePage();
}; }
return allOpts.server;
}; };
const writeStaticHtml = () => { const writeStaticHtml = () => {
// TODO: fix script paths // 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 // 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(`
<script src="./${path.basename(__filename)}"></script> <script src="./${path.basename(__filename)}"></script>
<script> <script>
window.require = () => window.SpaccDotWebServer; window.require = () => window.SpaccDotWebServer;
window.SpaccDotWebServer.resFilesData = { ${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')}"`;
})} }; })} };
</script> </script>
<script src="./${path.basename(process.mainModule.filename)}"></script> <script src="./${path.basename(process.mainModule.filename)}"></script>
`)); `));
return fileName;
}; };
const handleRequest = async (request, response={}) => { const handleRequest = async (request, response={}) => {
@ -87,66 +90,91 @@ const handleRequest = async (request, response={}) => {
request, request,
response, response,
urlSections: request.url.slice(1).toLowerCase().split('?')[0].split('/'), urlSections: request.url.slice(1).toLowerCase().split('?')[0].split('/'),
urlParameters: (new URLSearchParams(request.url.split('?')[1]?.join('?'))), urlParameters: Object.fromEntries(new URLSearchParams(request.url.split('?')?.slice(1)?.join('?'))),
bodyParameters: (request.method === 'POST' && 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),
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), redirectTo: (url) => redirectTo(response, url),
}; };
// client transitions // client transitions
if (envIsBrowser && document.querySelector(allOpts.server.transitionElement)) { if (envIsBrowser) {
document.querySelector(allOpts.server.transitionElement).style.display = 'block'; const transitionElement = document.querySelector(allOpts.server.transitionElement);
}; if (transitionElement) {
transitionElement.hidden = false;
transitionElement.style.display = 'block';
}
}
// 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)) {
const resPath = request.url.split(allOpts.global.staticPrefix).slice(1).join(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 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) }; result = { code: 200, headers: { 'content-type': mime.lookup(filePath) }, body: fs.readFileSync(filePath) };
} else { } else {
result = { code: 404 }; result = { code: 404 };
}; }
} else { } else {
// handle custom endpoints // handle custom endpoints
for (const [check, procedure] of allOpts.server.endpoints) { for (const [check, procedure] of allOpts.server.endpoints) {
if (await check(context)) { if (await requestCheckFunction(check, context)) {
result = await procedure(context); result = await procedure(context);
if (!allOpts.server.endpointsFalltrough) { if (!allOpts.server.endpointsFalltrough) {
break; break;
}; }
}; }
}; }
}; }
// finalize a normal response // finalize a normal response
if (result) { if (result) {
response.statusCode = result.code; response.statusCode = result.code;
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(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 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?
if (envIsNode) { if (envIsNode) {
response.setHeader('content-type', 'text/html; charset=utf-8'); 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) { if (envIsBrowser) {
document.title = allOpts.global.pageTitler(title); document.title = (opts.pageTitler || allOpts.global.pageTitler)(title);
document.querySelector(allOpts.server.appElement).innerHTML = allOpts.global.appPager(content, title); document.querySelector(allOpts.server.appElement).innerHTML = ((opts.appPager || allOpts.global.appPager)(content, title));
for (const srcElem of document.querySelectorAll(`[src^="${allOpts.global.staticPrefix}"]`)) { 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}"]`)) { 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^="/"]')) { for (const aElem of document.querySelectorAll('a[href^="/"]')) {
aElem.href = `#${aElem.getAttribute('href')}`; aElem.href = `#${aElem.getAttribute('href')}`;
}; }
for (const formElem of document.querySelectorAll('form')) { for (const formElem of document.querySelectorAll('form')) {
formElem.onsubmit = (event) => { formElem.onsubmit = (event) => {
event.preventDefault(); event.preventDefault();
@ -159,10 +187,12 @@ const renderPage = (response, content, title) => {
body: formData, body: formData,
}); });
}; };
}; }
if (document.querySelector(allOpts.server.transitionElement)) { const transitionElement = document.querySelector(allOpts.server.transitionElement);
document.querySelector(allOpts.server.transitionElement).style.display = 'none'; if (transitionElement) {
}; transitionElement.hidden = true;
transitionElement.style.display = 'none';
}
}; };
}; };
@ -171,10 +201,10 @@ const redirectTo = (response, url) => {
response.statusCode = 302; response.statusCode = 302;
response.setHeader('location', url); response.setHeader('location', url);
response.end(); response.end();
}; }
if (envIsBrowser) { if (envIsBrowser) {
location.hash = url; location.hash = url;
}; }
}; };
const parseBodyParams = async (request) => { const parseBodyParams = async (request) => {
@ -190,22 +220,22 @@ const parseBodyParams = async (request) => {
}; };
}); });
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()) {
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());
}; }
} else if (envIsBrowser && ['application/x-www-form-urlencoded', 'multipart/form-data'].includes(contentMime)) { } else if (envIsBrowser && ['application/x-www-form-urlencoded', 'multipart/form-data'].includes(contentMime)) {
for (const [key, value] of request.body) { for (const [key, value] of request.body) {
params[key] = value; params[key] = value;
params[key].filename = params[key].name; params[key].filename = params[key].name;
}; }
}; }
return params; return params;
} catch (err) { } catch (err) {
console.log(err); console.log(err);
@ -217,32 +247,31 @@ const getCookie = (request, name) => {
let cookies; let cookies;
if (envIsNode) { if (envIsNode) {
cookies = (request.headers?.cookie || ''); cookies = (request.headers?.cookie || '');
}; } else if (envIsBrowser) {
if (envIsBrowser) {
cookies = clientCookieApi(); cookies = clientCookieApi();
}; }
if (name) { if (name) {
// get a specific cookie // get a specific cookie
for (const cookie of (cookies?.split(';') || [])) { 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) { if (key === name) {
return rest.join('='); return rest.join('=');
}; }
}; }
} else { } else {
// get all cookies // get all cookies
return cookies; return cookies;
}; }
}; };
const setCookie = (response, cookie) => { const setCookie = (response, cookie) => {
if (envIsNode) { if (envIsNode) {
response.setHeader('Set-Cookie', cookie); response.setHeader('Set-Cookie', cookie);
// TODO update current cookie list in existing request to reflect new assignment // TODO update current cookie list in existing request to reflect new assignment
}; } else if (envIsBrowser) {
if (envIsBrowser) {
clientCookieApi(cookie); clientCookieApi(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:///)
@ -258,8 +287,8 @@ const clientCookieApi = (envIsBrowser && (document.cookie || (!document.cookie &
if (['expires', 'max-age'].includes(token.split('=')[0].toLowerCase())) { if (['expires', 'max-age'].includes(token.split('=')[0].toLowerCase())) {
api = localStorage; api = localStorage;
break; break;
}; }
}; }
key = `${gid}/${key}`; key = `${gid}/${key}`;
const value = rest.join('='); const value = rest.join('=');
if (value) { if (value) {
@ -267,16 +296,16 @@ const clientCookieApi = (envIsBrowser && (document.cookie || (!document.cookie &
} else { } else {
sessionStorage.removeItem(key); sessionStorage.removeItem(key);
localStorage.removeItem(key); localStorage.removeItem(key);
}; }
} else /*(get)*/ { } else /*(get)*/ {
let items = ''; let items = '';
for (const item of Object.entries({ ...localStorage, ...sessionStorage })) { for (const item of Object.entries({ ...localStorage, ...sessionStorage })) {
if (item[0].startsWith(`${gid}/`)) { if (item[0].startsWith(`${gid}/`)) {
items += (item.join('=') + ';').slice(gid.length + 1); items += (item.join('=') + ';').slice(gid.length + 1);
}; }
} }
return items.slice(0, -1); return items.slice(0, -1);
}; }
})); }));
const exportObj = { envIsNode, envIsBrowser, setup }; const exportObj = { envIsNode, envIsBrowser, setup };