Cloud Subnet Sizing (#11)

* Feature/cloud (#8)

* calc for cloud platforms

* updates after further testing

* remove debugging statements

---------

Co-authored-by: Caesar Kabalan <caesar.kabalan@gmail.com>
Co-authored-by: Byron Collins <byron_collins@hotmail.com>

* refactor cloud subnet sizes based on feedback

* added missing integrity SRI Hashes

* update set_popover_content

* Modify description to highlight  AWS and Azure capabilities.

---------

Co-authored-by: Caesar Kabalan <caesar.kabalan@gmail.com>
Co-authored-by: Byron Collins <byron_collins@hotmail.com>
This commit is contained in:
Byron Collins 2024-06-25 00:52:29 +09:00 committed by Caesar Kabalan
parent 6b054e9bb6
commit 6f55ba52ec
No known key found for this signature in database
GPG key ID: DDFEF5FF6CFAB608
8 changed files with 364 additions and 53 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
.idea/*
**/node_modules/*
**/certs/*

2
.nvmrc
View file

@ -1 +1 @@
v18.16.0
v20.12.2

View file

@ -45,8 +45,59 @@ Compile from source:
> npm start
```
The full application should then be available within `./dist/`, open `./dist/index.html` in a browser.
### Run with certificates (Optional)
***NB:*** *required for testing clipboard.writeText() in the browser. Feature is only available in secure (https) mode.*
```shell
#Install mkcert
> brew install mkcert
# generate CA Certs to be trusted by local browsers
> mkcert install
# generate certs for local development
> cd visualsubnetcalc/src
# generate certs for local development
> npm run setup:certs
# run the local webserver with https
> npm run local-secure-start
````
# Cloud Subnet Notes
- [AWS reserves 3 additional IPs](https://docs.aws.amazon.com/vpc/latest/userguide/subnet-sizing.html)
- [Azure reserves 3 additional IPs](https://learn.microsoft.com/en-us/azure/virtual-network/virtual-networks-faq#are-there-any-restrictions-on-using-ip-addresses-within-these-subnets)
## Standard mode:
- Smallest subnet: /32
- Two reserved addresses per subnet of size <= 30:
- Network Address (network + 0)
- Broadcast Address (last network address)
## AWS mode :
- Smallest subnet: /28
- Five reserved addresses per subnet:
- Network Address (network + 0)
- AWS Reserved - VPC Router
- AWS Reserved - VPC DNS
- AWS Reserved - Future Use
- Broadcast Address (last network address)
## Azure mode :
- Smallest subnet: /29
- Five reserved addresses per subnet:
- Network Address (network + 0)
- Azure Reserved - Default Gateway
- Azure Reserved - DNS Mapping
- Azure Reserved - DNS Mapping
- Broadcast Address (last network address)
## Credits
Split icon made by [Freepik](https://www.flaticon.com/authors/freepik) from [Flaticon](https://www.flaticon.com/).

5
dist/css/main.css vendored
View file

@ -255,3 +255,8 @@
white-space: nowrap;
padding-top: 0.25rem;
}
.active-mode {
color: #4091C9;
font-weight: bold;
}

35
dist/index.html vendored
View file

@ -6,7 +6,7 @@
<title>Visual Subnet Calculator - Split/Join</title>
<link rel="stylesheet" href="css/bootstrap.min.css">
<link href="css/main.css" rel="stylesheet">
<meta name="description" content="Quickly and easily design network layouts. Split and join subnets, add notes and color, then collaborate with others by sharing a custom link to your design.">
<meta name="description" content="Quickly and easily design network layouts. Split and join subnets, add notes and color, then collaborate with others by sharing a custom link to your design. Switch to AWS or Azure Mode to calculate compliant subnets for those Cloud Platforms">
<meta name="robots" content="index, follow" />
<link rel="apple-touch-icon" sizes="180x180" href="icon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="icon/favicon-32x32.png">
@ -36,11 +36,10 @@
<p class="mb-0">Enter the network you wish to subnet and use the Split/Join buttons on the right to start designing!</p>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<form id="input_form" class="row g-2 mb-3" novalidate>
<form id="input_form" class="row g-2 mb-3">
<div class="font-monospace col-lg-2 col-md-3 col-4">
<div><label for="network" class="form-label mb-0 ms-1">Network Address</label></div>
<div><input id="network" type="text" class="form-control" value="10.0.0.0" aria-label="Network Address" pattern="^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)" required></div>
<div><input name="network" id="network" type="text" class="form-control" value="10.0.0.0" aria-label="Network Address" pattern="^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)" required></div>
</div>
<div class="font-monospace col-auto">
<div style="height:2rem"></div>
@ -48,7 +47,7 @@
</div>
<div class="font-monospace col-lg-2 col-md-3 col-4">
<div><label for="netsize" class="form-label mb-0 ms-1">Network Size</label></div>
<div><input id="netsize" type="text" class="form-control w-10" value="16" aria-label="Network Size" pattern="^(\d|[12]\d|30)$" required></div>
<div><input name="netsize" id="netsize" type="text" class="form-control w-10" value="16" aria-label="Network Size" pattern="^([0-9]|[12][0-9]|3[0-2])$" required></div>
</div>
<div class="col-lg-2 col-md-3 col-3 font-">
<div style="height:1.5rem"></div>
@ -60,6 +59,11 @@
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#importExportModal" id="btn_import_export">Import / Export</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Mode</h6></li>
<li><a class="dropdown-item active-mode" href="#" data-bs-toggle="operatingMode" data-bs-target="#operatingMode" id="dropdown_standard">Standard</a></li>
<li><a class="dropdown-item" href="#" data-bs-toggle="operatingMode" data-bs-target="#operatingMode" id="dropdown_azure">Azure</a></li>
<li><a class="dropdown-item" href="#" data-bs-toggle="operatingMode" data-bs-target="#operatingMode" id="dropdown_aws">AWS</a></li>
</ul>
</div>
</div>
@ -67,17 +71,18 @@
</form>
<table id="calc" class="table table-bordered font-monospace">
<thead>
<tr>
<th id="subnetHeader" style="display: table-cell;">Subnet address</th>
<th id="netmaskHeader" style="display: none;">Netmask</th>
<th id="rangeHeader" style="display: table-cell;">Range of addresses</th>
<th id="useableHeader" style="display: table-cell;">Usable IPs</th>
<th id="useableHeader" style="display: table-cell;" data-bs-toggle="popover" title="Usable IPs" data-bs-content="This column shows the number of usable IP addresses in each subnet." data-bs-trigger="hover focus" data-bs-placement="top">Usable IPs</th>
<th id="hostsHeader" style="display: table-cell;">Hosts</th>
<th id="noteHeader" colspan="100%" style="display: table-cell;">
Note
<div style="display:inline-block; float:right;"><span class="split">Split</span>/<span class="join">Join</span></div>
<div style="float:right;"><span class="split">Split</span>/<span class="join">Join</span></div>
</th>
<!--
<th id="joinHeader" colspan="100%" style="display: table-cell;"><span class="split">Split</span>/<span class="join">Join</span></th>
@ -191,14 +196,26 @@
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ENjdO4Dr2bkBIFxQpeoTz1HIcje39Wm4jDKdf19U8gI4ddQ3GYNS7NTKfAdVQSZe"
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.7.0.min.js"
integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g="
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.19.5/dist/jquery.validate.min.js"
integrity="sha384-aEDtD4n2FLrMdE9psop0SHdNyy/W9cBjH22rSRp+3wPHd62Y32uijc0H2eLmgaSn"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.19.5/dist/additional-methods.min.js"
integrity="sha384-kxI94MBt6egf2HqINJ9x8sPSfqxudviPxgttYjlTFaPREJb/gmAOgTr/GYie5ung"
crossorigin="anonymous"></script>
<script src="js/lz-string.min.js"></script>
<script src="js/main.js"></script>
<script>
var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'))
var popoverList = popoverTriggerList.map(function (popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl)
})
</script>
</div>
</body>
</html>

283
dist/js/main.js vendored
View file

@ -7,7 +7,7 @@ let infoColumnCount = 5
// - Two reserved addresses per subnet of size <= 30:
// - Network Address (network + 0)
// - Broadcast Address (last network address)
// AWS mode (future):
// AWS mode :
// - Smallest subnet: /28
// - Two reserved addresses per subnet:
// - Network Address (network + 0)
@ -15,17 +15,39 @@ let infoColumnCount = 5
// - AWS Reserved - VPC DNS
// - AWS Reserved - Future Use
// - Broadcast Address (last network address)
let operatingMode = 'NORMAL'
// Azure mode :
// - Smallest subnet: /29
// - Two reserved addresses per subnet:
// - Network Address (network + 0)
// - Azure Reserved - Default Gateway
// - Azure Reserved - DNS Mapping
// - Azure Reserved - DNS Mapping
// - Broadcast Address (last network address)
let noteTimeout;
let minSubnetSize = 32
let operatingMode = 'Standard'
let previousOperatingMode = 'Standard'
let inflightColor = 'NONE'
let urlVersion = '1'
let configVersion = '1'
let urlVersion = '2'
let configVersion = '2'
const netsizePatterns = {
Standard: '^([0-9]|[12][0-9]|3[0-2])$',
AZURE: '^([0-9]|[12][0-9])$',
AWS: '^([0-9]|[12][0-8])$',
};
const minSubnetSizes = {
Standard: 32,
AZURE: 29,
AWS: 28,
};
$('input#network,input#netsize').on('input', function() {
$('#input_form')[0].classList.add('was-validated');
})
$('#color_palette div').on('click', function() {
// We don't really NEED to convert this to hex, but it's really low overhead to do the
// conversion here and saves us space in the export/save
@ -42,9 +64,51 @@ $('#calcbody').on('click', '.row_address, .row_range, .row_usable, .row_hosts, .
})
$('#btn_go').on('click', function() {
$('#input_form').removeClass('was-validated');
$('#input_form').validate();
if ($('#input_form').valid()) {
$('#input_form')[0].classList.add('was-validated');
reset();
// Additional actions upon validation can be added here
} else {
show_warning_modal('<div>Please correct the errors in the form!</div>');
}
})
$('#dropdown_standard').click(function() {
previousOperatingMode = operatingMode;
operatingMode = 'Standard';
if(!switchMode(operatingMode)) {
operatingMode = previousOperatingMode;
$('#dropdown_'+ operatingMode.toLowerCase()).addClass('active-mode');
}
});
$('#dropdown_azure').click(function() {
previousOperatingMode = operatingMode;
operatingMode = 'AZURE';
if(!switchMode(operatingMode)) {
operatingMode = previousOperatingMode;
$('#dropdown_'+ operatingMode.toLowerCase()).addClass('active-mode');
}
});
$('#dropdown_aws').click(function() {
previousOperatingMode = operatingMode;
operatingMode = 'AWS';
if(!switchMode(operatingMode)) {
operatingMode = previousOperatingMode;
$('#dropdown_'+ operatingMode.toLowerCase()).addClass('active-mode');
}
});
$('#importBtn').on('click', function() {
importConfig(JSON.parse($('#importExportArea').val()))
})
@ -73,17 +137,14 @@ $('#bottom_nav #copy_url').on('click', function() {
}, 2000)
})
$('#btn_import_export').on('click', function() {
$('#importExportArea').val(JSON.stringify(exportConfig(), null, 2))
})
function reset() {
if (operatingMode === 'AWS') {
minSubnetSize = 28
} else {
minSubnetSize = 32
}
set_popover_content(operatingMode);
let cidrInput = $('#network').val() + '/' + $('#netsize').val()
let rootNetwork = get_network($('#network').val(), $('#netsize').val())
let rootCidr = rootNetwork + '/' + $('#netsize').val()
@ -94,13 +155,13 @@ function reset() {
subnetMap = {}
subnetMap[rootCidr] = {}
maxNetSize = parseInt($('#netsize').val())
renderTable();
renderTable(operatingMode);
}
$('#calcbody').on('click', 'td.split,td.join', function(event) {
// HTML DOM Data elements! Yay! See the `data-*` attributes of the HTML tags
mutate_subnet_map(this.dataset.mutateVerb, this.dataset.subnet, '')
renderTable();
renderTable(operatingMode);
})
$('#calcbody').on('keyup', 'td.note input', function(event) {
@ -119,18 +180,18 @@ $('#calcbody').on('focusout', 'td.note input', function(event) {
})
function renderTable() {
function renderTable(operatingMode) {
// TODO: Validation Code
$('#calcbody').empty();
let maxDepth = get_dict_max_depth(subnetMap, 0)
addRowTree(subnetMap, 0, maxDepth)
addRowTree(subnetMap, 0, maxDepth, operatingMode)
}
function addRowTree(subnetTree, depth, maxDepth) {
function addRowTree(subnetTree, depth, maxDepth,operatingMode) {
for (let mapKey in subnetTree) {
if (mapKey.startsWith('_')) { continue; }
if (has_network_sub_keys(subnetTree[mapKey])) {
addRowTree(subnetTree[mapKey], depth + 1, maxDepth)
addRowTree(subnetTree[mapKey], depth + 1, maxDepth,operatingMode)
} else {
let subnet_split = mapKey.split('/')
let notesWidth = '30%';
@ -143,12 +204,12 @@ function addRowTree(subnetTree, depth, maxDepth) {
} else if (maxDepth > 20) {
notesWidth = '10%';
}
addRow(subnet_split[0], parseInt(subnet_split[1]), (infoColumnCount + maxDepth - depth), (subnetTree[mapKey]['_note'] || ''), notesWidth, (subnetTree[mapKey]['_color'] || ''))
addRow(subnet_split[0], parseInt(subnet_split[1]), (infoColumnCount + maxDepth - depth), (subnetTree[mapKey]['_note'] || ''), notesWidth, (subnetTree[mapKey]['_color'] || ''),operatingMode)
}
}
}
function addRow(network, netSize, colspan, note, notesWidth, color) {
function addRow(network, netSize, colspan, note, notesWidth, color, operatingMode) {
let addressFirst = ip2int(network)
let addressLast = subnet_last_address(addressFirst, netSize)
let usableFirst = subnet_usable_first(addressFirst, netSize, operatingMode)
@ -216,7 +277,9 @@ function subnet_usable_first(network, netSize, operatingMode) {
if (netSize < 31) {
// https://docs.aws.amazon.com/vpc/latest/userguide/subnet-sizing.html
// AWS reserves 3 additional IPs
return network + (operatingMode === 'AWS' ? 4 : 1);
// https://learn.microsoft.com/en-us/azure/virtual-network/virtual-networks-faq#are-there-any-restrictions-on-using-ip-addresses-within-these-subnets
// Azure reserves 3 additional IPs
return network + (operatingMode == 'Standard' ? 1 : 4);
} else {
return network;
}
@ -362,7 +425,7 @@ function mutate_subnet_map(verb, network, subnetTree, propValue = '') {
let netSplit = mapKey.split('/')
let netSize = parseInt(netSplit[1])
if (verb === 'split') {
if (netSize < minSubnetSize) {
if (netSize < minSubnetSizes[operatingMode]) {
let new_networks = split_network(netSplit[0], netSize)
// Could maybe optimize this for readability with some null coalescing
subnetTree[mapKey][new_networks[0]] = {}
@ -380,6 +443,16 @@ function mutate_subnet_map(verb, network, subnetTree, propValue = '') {
subnetTree[mapKey][new_networks[1]]['_color'] = subnetTree[mapKey]['_color']
}
delete subnetTree[mapKey]['_color']
} else {
switch (operatingMode) {
case 'AWS':
case 'AZURE':
show_warning_modal('<div>Minimum subnet size for ' + operatingMode + ' is ' + minSubnetSizes[operatingMode] + '</div>')
break;
default:
show_warning_modal('<div>Minimum subnet size is ' + minSubnetSizes[operatingMode] + '</div>')
break;
}
}
} else if (verb === 'join') {
// Options:
@ -402,6 +475,112 @@ function mutate_subnet_map(verb, network, subnetTree, propValue = '') {
}
}
function switchMode(operatingMode) {
let isSwitched = true;
if (subnetMap !== null) {
if (validateSubnetSizes(subnetMap, minSubnetSizes[operatingMode])) {
renderTable(operatingMode);
set_popover_content(operatingMode);
$('#netsize').attr("pattern",netsizePatterns[operatingMode]);
$('#input_form').removeClass('was-validated');
$('#input_form').rules("remove", "netsize");
switch (operatingMode) {
case 'AWS':
case 'AZURE':
var message = "("+operatingMode+") Smallest size is " + minSubnetSizes[operatingMode]
break;
default:
var message = "Smallest size is " + minSubnetSizes[operatingMode]
break;
}
// Modify jquery validation rule
$('#input_form #netsize').rules("add", {
required: true,
pattern: netsizePatterns[operatingMode],
messages: {
required: "Please enter a network size",
pattern: message
}
});
// Remove active class from all buttons if needed
$('#dropdown_standard, #dropdown_azure, #dropdown_aws').removeClass('active-mode');
$('#dropdown_' + operatingMode.toLowerCase()).addClass('active-mode');
isSwitched = true;
} else {
show_warning_modal('<div>Some subnets have a netmask size smaller than the minimum allowed for ' + operatingMode +'.</div><div>The smallest size allowed is ' + minSubnetSizes[operatingMode] + '</div>');
isSwitched = false;
}
} else {
//unlikely to get here.
reset();
}
return isSwitched;
}
function validateSubnetSizes(subnetMap, minSubnetSize) {
let isValid = true;
const validate = (subnetTree) => {
for (let key in subnetTree) {
if (key.startsWith('_')) continue; // Skip special keys
let [_, size] = key.split('/');
if (parseInt(size) > minSubnetSize) {
isValid = false;
return; // Early exit if any subnet is invalid
}
if (typeof subnetTree[key] === 'object') {
validate(subnetTree[key]); // Recursively validate subnets
}
}
};
validate(subnetMap);
return isValid;
}
function set_popover_content(operatingMode) {
var popoverContent = "This column shows the number of usable IP addresses in each subnet.";
var popoverTitle = "Usable IPs"
switch (operatingMode) {
case 'AWS':
case 'AZURE':
var popoverTitle = "Usable IPs (" + operatingMode + ")";
var popoverContent = "This column shows the number of usable IP addresses in each subnet. " + operatingMode + " reserves 5 IP Addresses"
break;
default:
var popoverContent = "This column shows the number of usable IP addresses in each subnet.";
var popoverTitle = "Usable IPs"
break;
}
// Ensure the popover is properly disposed
$('#useableHeader').popover('dispose');
// Reinitialize the popover with direct options for title and content
$('#useableHeader').popover({
trigger: 'hover',
html: true,
title: function() {
// You can compute or fetch the title dynamically here
return popoverTitle;
},
content: function() {
// You can compute or fetch the content dynamically here
return popoverContent;
}
});
}
function show_warning_modal(message) {
var notifyModal = new bootstrap.Modal(document.getElementById("notifyModal"), {});
@ -410,6 +589,39 @@ function show_warning_modal(message) {
}
$( document ).ready(function() {
// Initialize the jQuery Validation on the form
var validator = $('#input_form').validate({
rules: {
network: {
required: true,
pattern: "^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)"
},
netsize: {
required: true,
pattern: "^([0-9]|[12][0-9]|3[0-2])$"
}
},
messages: {
network: {
required: "Please enter a network",
pattern: "Must be a valid IP Address"
},
netsize: {
required: "Please enter a network size",
pattern: "Smallest size is 32"
}
},
errorPlacement: function(error, element) {
error.insertAfter(element);
},
// When the form is valid, add the 'was-validated' class
submitHandler: function(form) {
form.classList.add('was-validated');
form.submit(); // Submit the form
}
});
let autoConfigResult = processConfigUrl();
if (!autoConfigResult) {
reset();
@ -421,6 +633,7 @@ $( document ).ready(function() {
function exportConfig() {
return {
'config_version': configVersion,
'operating_mode': operatingMode,
'subnets': subnetMap,
}
}
@ -428,6 +641,7 @@ function exportConfig() {
function getConfigUrl() {
let defaultExport = JSON.parse(JSON.stringify(exportConfig()));
renameKey(defaultExport, 'config_version', 'v')
renameKey(defaultExport, 'operating_mode', 'm')
renameKey(defaultExport, 'subnets', 's')
shortenKeys(defaultExport['s'])
return '/index.html?c=' + urlVersion + LZString.compressToEncodedURIComponent(JSON.stringify(defaultExport))
@ -441,14 +655,18 @@ function processConfigUrl() {
// First character is the version of the URL string, in case the mechanism of encoding changes
let urlVersion = params['c'].substring(0, 1)
let urlData = params['c'].substring(1)
if (urlVersion === '1') {
let urlConfig = JSON.parse(LZString.decompressFromEncodedURIComponent(params['c'].substring(1)))
if (urlVersion === '2') {
renameKey(urlConfig, 'm','operating_mode')
}
renameKey(urlConfig, 'v', 'config_version')
renameKey(urlConfig, 's', 'subnets')
expandKeys(urlConfig['subnets'])
importConfig(urlConfig)
return true
}
}
}
@ -500,14 +718,25 @@ function renameKey(obj, oldKey, newKey) {
}
function importConfig(text) {
// TODO: Probably need error checking here
if (text['config_version'] === '1') {
switch (text['config_version']) {
case '1':
operatingMode = 'Standard';
break;
case '2':
operatingMode = text['operating_mode'];
break;
default:
// Optionally handle unexpected config_version values
show_warning_modal('<div>Invalid operating_mode</div>');
reset();
break;
}
let subnet_split = Object.keys(text['subnets'])[0].split('/')
$('#network').val(subnet_split[0])
$('#netsize').val(subnet_split[1])
subnetMap = text['subnets'];
renderTable()
}
switchMode(operatingMode);
}
const rgba2hex = (rgba) => `#${rgba.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+\.{0,1}\d*))?\)$/).slice(1).map((n, i) => (i === 3 ? Math.round(parseFloat(n) * 255) : parseFloat(n)).toString(16).padStart(2, '0').replace('NaN', '')).join('')}`

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "visualsubnetcalc",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -1,12 +1,14 @@
{
"dependencies": {
"bootstrap": "^5.3.2",
"lz-string": "^1.5.0",
"http-server": "^14.1.1"
"http-server": "^14.1.1",
"lz-string": "^1.5.0"
},
"scripts": {
"postinstall": "npm install -g sass",
"postinstall": "sudo npm install -g sass",
"build": "sass --style compressed scss/custom.scss:../dist/css/bootstrap.min.css && cp node_modules/lz-string/libs/lz-string.min.js ../dist/js/lz-string.min.js",
"start": "node node_modules/http-server/bin/http-server ../dist -c-1"
"setup:certs": "mkdir -p certs; mkcert -cert-file certs/cert.pem -key-file certs/cert.key localhost 127.0.0.1",
"start": "node node_modules/http-server/bin/http-server ../dist -c-1",
"local-secure-start": "node node_modules/http-server/bin/http-server ../dist -c-1 -C certs/cert.pem -K certs/cert.key -S -p 8443"
}
}