Introduction
A few weeks back, I published a script that automates the process of creating compilation posts for @ocdb Curators. It runs on Node.js, but then I started thinking maybe I could reduce the friction a little bit if running the script wouldn't require installing Node.js and could be done in the browser (which natively runs JavaScript).
So I started looking at ways to do this, and in this post I'm going to show my first attempt at this and detail the most important issues I faced while trying to get this to work.
Adapting the JavaScript file to run in a browser
My goal was to create a hybrid file that would be compatible both with Node.js and running in a browser. Although I succeeded, I regret having established that goal. A lot of tasks are just completely different and I don't think there isn't much to gain in splitting the logic across the entire procedure between two distinct ways of doing the same thing. In this case I think it makes sense to create one file for each case, which I did (Node.js and browser).
In any case here is also a file that works with both, and below I highlight some of the more important bits to make this work.
Passing command-line arguments
With Node.js, one simply adds the arguments after the execution command, like for example
npm start -- compilation_1.json
node index.js compilation_1.json
And then pick up 'compilation_1.json' with process.argv[2]
. But this doesn't work in a script from the browser. So a solution would be to check the environment and assign the arguments to a variable differently, depending on that environment. So something like this in the .js file works:
let argvs = null;
const isNode = typeof process !== 'undefined' && process.versions?.node;
if (isNode) {
if (process.argv[2]) argvs = process.argv.slice(2);
} else {
const argsInput = document.getElementById('arguments');
if (argsInput) {
argvs = argsInput.value.trim()
.split(/\s+/) // Split by whitespace
.filter(arg => arg.length > 0); // Remove empty strings
}
}
It accepts from zero to as many arguments as you need without breaking the code downstream while also taking parameters from the browser as a string separated by spaces.
Accepting user-defined modules
This part was a lot trickier. Say for example you put some logic in external module files to keep things more compact:
import { FetchHiveData, ExtractParams } from './api.js';
Getting the browser to accept such an import took me a while to figure out. There are a lot of restrictions...
I tried initially to load the HTML file directly, but any 'simple' attempts to make it work were all failing. In the end, the simplest solution I could find was to run a simple HTTP server on the project's folder with Python (comes included), which may still need to be installed in Windows. Fortunately, installing Python is a straightforward process.
$ python3 -m http.server
The script core logic in the HTML file looks like this as a Promise, at the same time passing along the parameters:
import(`./${file}`)
.catch(err => {
console.warn(`Failed to load ${file}`);
console.error(`Error: ${err.message}`);
if (err.stack) {
console.log(err.stack);
}
})
.finally(() => {
console.log('Module execution completed.');
});
I also tried to use Blorb and URL classes instead, but ultimately I wasn't able to make it work with the user-defined modules.
const blob = new Blob([scriptContent], { type: 'application/javascript' });
const blobURL = URL.createObjectURL(blob);
const args = argsInput.value.trim().split(/\s+/);
try {
const module = await import(blobURL);
if (module.main) {
module.main(...args); // Run the main function with arguments
}
The simplest solution is of course to do away with the user-defined modules and condense all the logic in a single file, but that defeats the purpose of having a single set of files compatible with both ways to execute them, while keeping the code organised and easy to maintain. This approach would also avoid having to use a server to load the HTML page.
Replacing Node.js core modules
Another hairy situation is how to replace the Node.js core module imports, like
import fs from 'fs';
import path from 'path';
There are no direct equivalents for the browser, so the solution was to build custom functions like these two:
const BuildFilePath = (dir, filename) => {
if (isNode) {
return path.format({
dir,
base: filename
});
} else {
if (!dir.endsWith('/')) {
dir += '/';
}
return dir + filename;
}
}
const ReadFileData = async (filepath) => {
if (isNode) {
return fs.readFileSync(filepath, 'utf-8');
} else {
const response = await fetch(filepath);
return await response.text();
}
}
Saving the file to disk
If you don't want to copy the markdown document from the screen and would rather keep a copy on disk, the methods to do it are different. In the case of the browser you can't save the file directly, but you can download it. Here's the difference:
if (isNode) {
fs.writeFileSync(outputFile, outputMd);
} else {
const blob = new Blob([outputMd], { type: 'text/plain' });
const link = document.createElement('a');
link.download = mdFilename;
link.href = URL.createObjectURL(blob);
link.click();
URL.revokeObjectURL(link.href);
}
HTML file to load and run JavaScript modules
This HTML document allows selecting a local .js file and executes it, bypassing the need to install Node.js. As I mentioned at the beginning, the .js files need to be prepared with usage in a browser in mind, and some trivial tasks in Node.js may require a fair bit of trial and error to get to work in a browser. But overall I'm happy with the result. I think it is a good template to give to someone else to run a script without having to do any software installation, if the script is simple enough. But embedding the JavaScript logic in an HTML file itself may be even simpler (and perhaps faster to code, with fewer roadblocks).
You can find the complete file here. Below is a highlight of the more important pieces.
Override console to also print on screen and with colors for 'warn' and 'error'
I wanted to be able to print both in the console and in the page, and I also wanted to color code the error messages. The way I decided to do it was to highjack and modify the console
object. Here's how I did it:
const DualOutput = (method) => {
return (...args) => {
method.apply(console, args); // Still print to console
const methodType = method.name;
if (methodType === 'log') {
const logMessage = args.join(' ');
outputDiv.textContent += "\n" + logMessage;
outputDiv.scrollTop = outputDiv.scrollHeight;
} else {
const logMessage = args.join(' ');
const messageElement = document.createElement('div');
messageElement.classList.add(methodType);
messageElement.textContent = logMessage;
outputDiv.appendChild(messageElement);
outputDiv.scrollTop = outputDiv.scrollHeight;
}
}
}
const originalConsole = {
log: console.log,
error: console.error,
warn: console.warn
};
console.log = DualOutput(originalConsole.log);
console.warn = DualOutput(originalConsole.warn);
console.error = DualOutput(originalConsole.error);
Store and reload the arguments, with a debounce function
The debounce function prevents the event listener that saves the arguments string from triggering on every keypress. It uses a closure approach to initialize the timeout function and then execute it when the user types in the Arguments field.
// Store arguments whenever they change (debounced)
argsInput.addEventListener('input', Debounce(() => {
localStorage.setItem('lastArgs', argsInput.value);
}, 500));
// Debounce function for text field
function Debounce(func, delay) {
let timeout;
return function() {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, arguments), delay);
};
}
// Auto-fill arguments field
const lastArgs = localStorage.getItem('lastArgs');
if (lastArgs) argsInput.value = lastArgs;
Execute the loaded script
Here are 2 ways to execute the script. Initially I was using a Promise-based approach, but for some reason I don't understand, the console overrides don't work properly, the color styles are not applied.
So I switched to a try...catch logic, but this way the errors are not caught there, they get handled by window.addEventListener('unhandledrejection', (event)
upstream. I would still like to understand why the Promise doesn't use the modified console and if I can make this work without triggering an 'unhandledrejection' event (with colors).
// Import and run the selected script
try {
import(`./${file}`);
} catch (error) {
// never reached
// goes directly to window.addEventListener('unhandledrejection'
}
/*
import(`./${file}`)
.catch(err => {
console.warn(`Failed to load ${file}`);
console.error(`Error: ${err.message}`);
if (err.stack) {
console.log(err.stack);
}
})
.finally(() => {
console.log('Module execution completed.');
});
*/
Conclusion
Although it is possible to execute scripts in individual files from the browser, there are a lot of safety restrictions that make that task a lot harder to get working than running everything from a single HTML page.
I still like the idea of sharing a simple tool publicly that only requires loading a webpage. I'll be looking at more opportunities to create templates and code blocks for this purpose.
This solution for the script to create a compilation post still requires a running HTTP server, being the simplest solution to use the one in Python, although asking someone to install Python defeats the purpose of executing the script without any installation steps. I may revisit this in the future for a better solution.
I hope you found this post useful and interesting.
Thanks for reading!
Hi, I love programming to create algorithms that automate processes, nice post my friend.👍👍👍