Using the File System API
Using the File System API
Making a web application that can open/save files
Although it is possible to have a Desktop application that can open/save files locally, this has not been a feature of web applications. However, in certain circumstances, you can have a web app that can open and save files. This web app must be served out using https.
Using mkcert to allow serving out https locally
Here is the GitHub site for mkcert. As stated on that site, mkcert is a simple tool used to make locally-trusted development certificates. As such, it is a very good tool for working on web applications that need to be broadcast using https without having to set up a remote server with actual SSL certificates.
Since I wanted to just learn about using the File System API, I installed and setup mkcert so that I could do everything locally.
Installing and setting up mkcert
The mkcert GitHub site has instructions on installing mkcert on different operating systems. This is what I did for my Ubuntu 24.04 machine.
$ sudo apt update
$ sudo apt install libnss3-tools
$ sudo apt install mkcert
To setup mkcert before generating any certificates, you have to run the following command:
$ mkcert -install
Web browsers need to be restarted after that setup command is run. The File System API is supported on recent versions of Chrome, Chromium and Edge. I use Chrome and Chromium. This is not supported on Firefox. As such, you may not want to use the File System API, if you need to support other browsers.
Installing and configuring Nginx
To serve out the pages, I used the Nginx server as this is simpler than using Apache. Here is how Nginx is setup on Ubuntu 24.04
$ sudo apt update
$ sudo apt install nginx
If you go to a browser and open http://localhost, you should see the Nginx welcome message.
Setting up a virtual host for Nginx
Start by going to the following directory /etc/nginx/sites-available. Create a file that will be the configuration file for Nginx. Here is the file app.mysite.com.conf:
server {
listen 443 ssl;
server_name app.mysite.com;
root /var/www/html;
ssl_certificate /path/to/file.pem;
ssl_certificate_key /path/to/file-key.pem;
# Add index.php to the list if you are using PHP
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Lines 7 and 8 need to be filled in with the correct filenames. This will be determined when we use mkcert to actually generate the certificates.
Generating the mkcert certificates
I first navigated to the location where I wanted to store my mkcert certificates. Then, I ran the mkcert command:
$ cd /path/to/location_of_certificates
$ mkcert app.mysite.com localhost 127.0.0.1 ::1
This results in the following files being created:
/path/to/location_of_certificates/app.mysite.com+3.pem
/path/to/location_of_certificates/app.mysite.com+3-key.pem
Substitute those into the Nginx configuration file above and save that file
Enabling the new site
Now that you have created the configuration file for you new server, there are several things you should do to enable the new site. The first thing I did was to go to /etc/nginx/sites-enable and delete any symbolic links. There probably is one called default that links to /etc/nginx/sites-available/default. Then, I created a symbolic link to the new configuration file:
$ cd /etc/nginx/sites-enabled
$ sudo rm default
$ sudo ln -s /etc/nginx/sites-available/app.mysite.com.conf
You should check to see if there are any configuration problems by running:
$ sudo nginx -t
If there are no errors, you can restart the nginx service.
$ sudo service nginx restart
The next thing I did was to add an entry into the /etc/hosts file:
127.0.0.1 localhost
127.0.0.1 app.mysite.com
Utilizing the ufw firewall
On Ubuntu, the Uncomplicated Firewall (ufw) can be set up easily. Here are the steps that I did to support my local https experiments.
$ sudo ufw status
Status: inactive
$ sudo ufw enable
Firewall is active and enabled on system startup
$ sudo ufw allow 443/tcp
Rules updated
Rules updated (v6)
$ sudo ufw status
Status: active
To Action From
-- ------ ----
443/tcp ALLOW Anywhere
443/tcp (v6) ALLOW Anywhere (v6)
Creating a simple web page as a test
To test out my setup, I did the following:
$ cd /var/www/html
$ sudo mkdir webarea
$ sudo chown -R username:usernae webarea
Substitute your own username for username in the last command. Now, I have a folder that I own and can create my files for testing. I started off with a simple index.html file:
<!DOCTYPE html>
<html>
<body>
<h1>This is my area for testing https locally</h1>
</body>
</html>
Then, I open my browser (make sure you restarted it after running mkcert -install) to the following URL:
*https://app.mysite.com/webarea*
Here is a screen shot of what this looks like in Chromium:
A working example
Next, I created a page that would be able to open/save a file. In the first version, this simple app will just open a file and write the contents to the Dev Tools console:
<!DOCTYPE>
<html>
<head>
<script>
document.addEventListener('DOMContentLoaded', init);
let fileHandle;
async function init() {
[fileHandle] = await window.showOpenFilePicker();
}
</script>
</head>
<body>
</body>
</html>
This will result in an error, which is shown in the next screen shot:
As you can see, trying to use window.showOpenFilePicker() by calling it directly on line 9 results in an error. This is a security measure that the browser enforces. Basically, you cannot open the FilePicker unless this is in response to a user gesture, such as the user clicking on a button.
So, here is the next version of the app that addresses this:
<!DOCTYPE>
<html>
<head>
<script>
document.addEventListener('DOMContentLoaded', init);
let fileHandle;
async function openFile() {
[fileHandle] = await window.showOpenFilePicker();
}
function init() {
const open_file = document.getElementById('open_file');
open_file.addEventListener('click', openFile);
}
</script>
</head>
<body>
<button id="open_file">Open a file</button>
</body>
</html>
The new lines are lines 8-10, 13-14 and 20. Actually, line 12 was changed as the init() function no longer needs to be declared async. Let’s look at the changes to the markup and work our way back to the rest of the changes. Line 20 adds a button element with an id="open_file" attribute. This allows getting a reference to that button on line 13. Line 14 makes it so that when that button is clicked, the openFile() function is called.
Lines 8-10 define the openFile() function. Now, this function is called in response to a user gesture, clicking on the "Open a file" button. So, line 9 will succeed in opening a File Open dialog box.
The next screen shot will show the File Open dialog box being opened:
The next screen shot shows what happens if the user hits Cancel for the file dialog box:
This is a reminder, that the openFilePicker() method returns a Promise, and we should handle that accordingly. Here is a version of the app that handles the Promise, and if a file handle is obtained, it prints the contents of the file associated with that handle to the console.
<!DOCTYPE>
<html>
<head>
<script>
document.addEventListener('DOMContentLoaded', init);
let fileHandle;
async function openFile() {
try {
[fileHandle] = await window.showOpenFilePicker();
const file = await fileHandle.getFile();
const contents = await file.text();
console.log(contents);
}
catch(error) {
if (error.name === 'AbortError') {
alert('user hit cancel');
}
else {
console.log('error occurred:', error);
}
}
}
function init() {
const open_file = document.getElementById('open_file');
open_file.addEventListener('click', openFile);
}
</script>
</head>
<body>
<button id="open_file">Open a file</button>
</body>
</html>
The new lines are lines 9-22. Lines 9-22 make use of a try/catch block to handle the Promise. Lines 9-14 handle the case where the Promise is resolved. Line 10 ensures that if the Promise is resolved, fileHandle will have the file handle to the first of possily multiple handles returned. Line 11 will call the getFile() method to get the actual physical file. This can take some time, so await is used to make sure that the getFile() method completes. Line 12 uses file.text() to read the contents of the physical file. Since this can take time to complete, await is used to wait until all the contents are obtained. Line 13 just prints the contents out to the Dev Tools console.
Lines 15-22 handle the catch block. Lines 16-18 handle the case where the error is an 'AbortError'. This is the type of error that is thrown when the user hits Cancel in the file dialog. Line 17 just uses alert() to show that the user hit cancel. Lines 19-21, will handle any other errors that can occur in the try block. So, for example, if the file cannot be found or has permission problems, that would be a possible error. So, lines 19-21 will just catch any errors besides an 'AbortError' that occurs while executing the try block.
Here is what the app looks like after the user hits cancel in response to the file dialog box:
If you did run the app and clicked cancel and dismissed the alert, there would be no errors in the console.
Here is what you will see if you open a file. In this case, I opened the "antora.yml" file:
Using JSON files
Although not necessary at this point, I will make it so that my app expects to open (and eventually save to) a JSON file. A JSON file would be useful for storing configuration information and can be handled natively with JavaScript. This makes it useful for storing app data, including game state data (if creating a game).
Just to demonstrate how you can set options for the File Picker, here is a version of the app that will only display JSON files:
<!DOCTYPE>
<html>
<head>
<script>
document.addEventListener('DOMContentLoaded', init);
let fileHandle;
const pickerOpts = {
types: [
{
description: "JSON files",
accept: {
"application/json": [".json"],
},
}
],
excludeAcceptAllOption: true,
multiple: false,
};
async function openFile() {
try {
[fileHandle] = await window.showOpenFilePicker(pickerOpts);
const file = await fileHandle.getFile();
const contents = await file.text();
console.log(contents);
}
catch(error) {
if (error.name === 'AbortError') {
alert('user hit cancel');
}
else {
console.log('error occurred:', error);
}
}
}
function init() {
const open_file = document.getElementById('open_file');
open_file.addEventListener('click', openFile);
}
</script>
</head>
<body>
<button id="open_file">Open a file</button>
</body>
</html>
Lines 7-22 define the constant pickerOpts. Lines 8-15 define the types of files to display. As you can see, I tried to set this up so that only JSON files will be displayed in the File Picker. Line 16 makes it so that the user does not have the option to display all files, and line 17 makes it so that the user can select only a single file.
Line 22 changes the call to showOpenFilePicker() by passing the pickerOpts as the argument.
Here is a screen shot showing the File Picker being displayed when the user clicks on the "Open a file" button:
However, if the user changes to a different directory from the one first opened, then "JSON files" is an option but "(None)" is the displayed first choice.
I could not figure out how to make "JSON files" always the first choice when changing directories. This may or may not be a big deal to you. There probably is a way to add code to the JavaScript so that it can enforce this, but I did not spend time on this.
Converting JSON files to JavaScript objects
Let’s modify the app so that it converts the contents of the JSON file, into actual JavaScript objects. First, let’s create a JSON file for this purpose. Here is the file "students.json":
{
"students": [
{
"first_name": "Jane",
"last_name": "Doe",
"major": "Biology"
},
{
"first_name": "John",
"last_name": "Doe",
"major": "Math"
}
]
}
Here is a version of the app that will read in this file and create a JavaScript object that can be manipulated.
<!DOCTYPE>
<html>
<head>
<script>
document.addEventListener('DOMContentLoaded', init);
let fileHandle;
let data;
const pickerOpts = {
types: [
{
description: "JSON files",
accept: {
"application/json": [".json"],
},
}
],
excludeAcceptAllOption: true,
multiple: false,
};
async function openFile() {
try {
[fileHandle] = await window.showOpenFilePicker(pickerOpts);
const file = await fileHandle.getFile();
const contents = await file.text();
data = JSON.parse(contents);
console.log(data);
console.log(data.students[0].first_name);
}
catch(error) {
if (error.name === 'AbortError') {
alert('user hit cancel');
}
else {
console.log('error occurred:', error);
}
}
}
function init() {
const open_file = document.getElementById('open_file');
open_file.addEventListener('click', openFile);
}
</script>
</head>
<body>
<button id="open_file">Open a file</button>
</body>
</html>
The new lines are lines 26-28. Line 26 uses JSON.parse() to convert the text contents into a JavaScript object. Line 27 prints that object. Line 28 prints just the first_name property of the first element in the array pointed to by data.students.
Here is a screen shot showing the output in the Dev Tools console:
Writing to a JSON file
Next, I modified the app so that it would change the contents of the data, and save the modified data to another JSON file.
Here is the new version of the app:
<!DOCTYPE>
<html>
<head>
<script>
document.addEventListener('DOMContentLoaded', init);
let fileHandle;
let data;
const pickerOpts = {
types: [
{
description: "JSON files",
accept: {
"application/json": [".json"],
},
}
],
excludeAcceptAllOption: true,
multiple: false,
};
async function openFile() {
try {
[fileHandle] = await window.showOpenFilePicker(pickerOpts);
const file = await fileHandle.getFile();
const contents = await file.text();
data = JSON.parse(contents);
console.log(data);
console.log(data.students[0].first_name);
}
catch(error) {
if (error.name === 'AbortError') {
alert('user hit cancel');
}
else {
console.log('error occurred:', error);
}
}
}
async function saveFile() {
try {
const newHandle = await window.showSaveFilePicker();
const outfile = await newHandle.createWritable();
data.students[0].last_name = "Smith";
const contents2 = await JSON.stringify(data);
console.log(contents2);
await outfile.write(contents2);
await outfile.close();
}
catch(error) {
}
}
function init() {
const open_file = document.getElementById('open_file');
open_file.addEventListener('click', openFile);
const save_file = document.getElementById('save_file');
save_file.addEventListener('click', saveFile);
}
</script>
</head>
<body>
<button id="open_file">Open a file</button>
<button id="save_file">Save file</button>
</body>
</html>
The new lines are lines 40-53, 58-59 and line 66. Let’s start with the changes to the markup on line 66. Line 66 adds a <button> element with an id="save_file" attribute. This id allows a reference to this save button to be obtained on line 58. Line 59, then associates the saveFile() function with clicking on the save button.
Lines 40-53 define the saveFile() function. Lines 41-49 define the try block. Line 42 tries to get file handle for the output file by calling the showSaveFilePicker() function. One error that can be thrown by this call would be an 'AbortError' that would be the result of the user hitting Cancel instead of choosing an output filename. Line 43 creates the outfile object that allows writing to the physical file. This file would be using the name that the user selected in response to the Save File Picker dialog box. Line 44 changes the contents of the data object. Line 45 converts that object into a string using JSON.stringify(). Line 46 prints that string to the console for debugging purposes. Line 47 writes that string to the outfile object and line 48 flushes the buffer and writes to the physical file.
Lines 50-52 define the catch block. We could catch the 'AbortError' here, but since not catching that will not affect writing to the file, I just left that out. This like instructing the program to do nothing if the user hits Cancel when saving the file.
Here is a screen shot showing the Dev Tools console when running the program. You can see from the console output that the last_name for the first student has been changed from 'Doe' to 'Smith':
Here is the contents of the file students2.json that I saved:
{"students":[{"first_name":"Jane","last_name":"Smith","major":"Biology"},{"first_name":"John","last_name":"Doe","major":"Math"}]}
I modified the program a final time to make the output look nicer. Here is the final version of the app.
<!DOCTYPE>
<html>
<head>
<script>
document.addEventListener('DOMContentLoaded', init);
let fileHandle;
let data;
const pickerOpts = {
types: [
{
description: "JSON files",
accept: {
"application/json": [".json"],
},
}
],
excludeAcceptAllOption: true,
multiple: false,
};
async function openFile() {
try {
[fileHandle] = await window.showOpenFilePicker(pickerOpts);
const file = await fileHandle.getFile();
const contents = await file.text();
data = JSON.parse(contents);
console.log(data);
console.log(data.students[0].first_name);
}
catch(error) {
if (error.name === 'AbortError') {
alert('user hit cancel');
}
else {
console.log('error occurred:', error);
}
}
}
async function saveFile() {
try {
const newHandle = await window.showSaveFilePicker();
const outfile = await newHandle.createWritable();
data.students[0].last_name = "Smith";
const contents2 = await JSON.stringify(data, null, 2);
console.log(contents2);
await outfile.write(contents2);
await outfile.close();
}
catch(error) {
}
}
function init() {
const open_file = document.getElementById('open_file');
open_file.addEventListener('click', openFile);
const save_file = document.getElementById('save_file');
save_file.addEventListener('click', saveFile);
}
</script>
</head>
<body>
<button id="open_file">Open a file</button>
<button id="save_file">Save file</button>
</body>
</html>
The modified line, is line 45. By using this command:
JSON.stringify(data, null, 2)
the output will be indented using 2 spaces for each level. When the app is run again, this is what the output file will look like:
{
"students": [
{
"first_name": "Jane",
"last_name": "Smith",
"major": "Biology"
},
{
"first_name": "John",
"last_name": "Doe",
"major": "Math"
}
]
}
Of course, a more practical program would allow the user to make the changes that will be written to the output file. But, this app does demonstrate the basics of being able to open/save files from a web application. One of the exercises will provide practice for a more practical example.