Making a proxy frontend from scratch (Part 2)
Welcome to my guide on creating a proxy frontend from scratch! This is the second part of this guide! You can find part one here
- This part is going to go through the 3 things:
- Basic frontend creation
- Installation and explanation of Ultraviolet
- Installation and explanation of Bare Mux (V2 only)
- Usage of Ultraviolet
Prerequisites
- Node.js (Version: 21 or later) & NPM (this should be installed alongside Node.js)
- You need to read Part 1 of this guide
Frontend
- We need to setup a basic frontend for everything to work. Let's start doing that now.
- Install all of these packages
npm i @mercuryworkshop/bare-mux @mercuryworkshop/epoxy-transport @titaniumnetwork-dev/ultraviolet
- Don't worry about what these packages do for now, we'll see what they do later.
- Create a folder called
public
in your project. (You can name this folder whatever you want just remember the name for later!)
Example command using Linux & the terminal:
mkdir public/
- Create a file called
index.html
in thepublic
folder.
Example command using Linux & the terminal:
touch public/index.html
- Now let's edit the file! Use whatever text editor you prefer.
Example command using Linux, the terminal & vim:
cd public/ && vim index.html
The file should be blank. Let's add some content!
<!DOCTYPE html>
<html>
<head>
<!-- Basic boilerplate nothing interesting going on here -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no" />
<title> Prism </title>
<!-- End basic boilerplate -->
<style>
/** Basic CSS boilerplate **/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100%;
height: 100vh;
}
/** End basic CSS boilerplate **/
/** add the "dnone" class (display none) **/
.dnone {
display: none;
visibility: hidden;
width: 0%;
height: 0%;
}
/** Style the "main" div **/
#main {
width: 100%;
height: 100%;
background: black;
display: flex;
flex-direction: column;
gap: 2rem;
justify-content: center;
align-items: center;
}
/** Style the title **/
#title {
color: white;
font-weight: 900;
font-size: 4rem;
}
/** Style the description **/
#description {
color: white;
font-weight: 200;
opacity: 0.75;
}
/** Style the input (address bar) **/
#address {
width: 30rem;
height: 4rem;
background: #33001a;
color: white;
outline: none;
border: none;
padding: 14px;
font-size: 1rem;
font-weight: 900;
border-radius: 1rem;
}
/** Style the iframe **/
#frame {
width: 100%;
height: 100%;
/** Set position to absolute. This allows us to open the iframe ON TOP of everything **/
position: absolute;
top: 0;
bottom: 0;
background: black;
outline: none;
border: none;
}
</style>
</head>
<body>
<div id="main">
<!-- Title -->
<h1 id="title"> Prism </h1>
<!-- Description -->
<p id="description"> A barebones example from MotorTruck1221's guide "Creating a proxy frontend from scratch" </p>
<!-- Search bar -->
<input id="address" type="text" placeholder="Search..." />
<!-- iframe where the content will be loaded -->
<iframe id="frame" class="dnone"></iframe>
</div>
</body>
</html>
- Now let's get the server to send the frontend instead of just the basic "Hello world!" response.
Delete these lines:
//basic hello world. We will change this later!
app.get('/', (req, res) => {
//send a response of hello world!
res.send('Hello World!');
});
Add these lines in place of it:
import { path } from 'node:path'; // ADD this at the TOP of the file!! NOT here!
app.use(express.static(path.join(import.meta.dirname, "public" /* This is the folder you created with the index.html file in it */)));
The final example should look like this:
//Import express, http and wisp
import http from 'node:http';
import express from 'express';
import wisp from 'wisp-server-node';
import path from 'node:path'; // we JUST imported this!
//create the express "app"
const app = express();
//create an http server
const httpServer = http.createServer();
//define the port to listen on
//change this to your liking!
const port = 8080;
app.use(express.static(path.join(import.meta.dirname, "public" /* This is the folder you created with the index.html file in it */))); // We JUST added this line
//listen for requests on the http server.
httpServer.on('request', (req, res) => {
//make express handle all of the requests
app(req, res)
});
//listen for websocket upgrades on the http server
httpServer.on('upgrade', (req, socket, head) => {
if (req.url.endsWith('/wisp/')) {
//route the request to the wisp server if the url ends in /wisp/
wisp.routeRequest(req, socket, head);
}
else {
socket.end();
}
});
//when the server is ready, console.log that it is ready
httpServer.on('listening', () => {
console.log(`Server listening on http://localhost:${port}`);
});
//start the http server
httpServer.listen({
port: port
});
- And finally, let's add a script to our
package.json
file to start the server easier:
"scripts": {
"start": "node index.js"
}
The final example should look like this:
{
"name": "prism",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js", // THIS IS THE LINE WE ADDED
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "AGPL-3.0-only",
"dependencies": {
"express": "^4.19.2",
"wisp-server-node": "^1.1.3"
}
}
Now let's get onto adding Ultraviolet!
Ultraviolet
Explantion
- Firstly we need to understand how something like Ultraviolet works. Let's take our previous chart for example:
- We understand how that works right? Let's expand on it! Here's our new flow chart:
- Whoah! That's a lot of new stuff right? Let's break it down.
- First you the browser (in white in the diagram), commonly referred to as the client makes a basic request. The server (also in white in the diagram) then sends the basic frontend we wrote to you.
- Once we receive the frontend, we install a service worker (Ulraviolet, in green in the diagram).
- Once the service worker is ready, we set a transport (don't worry too much about what this is it'll be explained later). This transport then sends a "continue" packet (Wisp packet, the color red in the diagram).
- As soon as the client (you or anyone else) types in a URL, we encode the URL with XOR and send a request to the service worker.
- The service worker (in green in the diagram), starts to communicate using the transport. This performs a TLS handshake to create an encrypted (the color orange in the diagram) connection.
- The service worker makes an encrypted (the color orange in the diagram) request, and as soon as the service worker receives the response, it rewrited all url's. For example:
- All CSS urls:
@import "url here"
- All urls found in the HTML:
<script src="https://thescripthere"></script>
- And the JavaScript urls (this is the difficult part) eg:
window.location
- This rewritten content is then what you see.
- But why does the content need to be rewritten?
- It needs to be rewritten as if it wasn't, you would be getting the data directly from the actual website instead of getting it through the proxy. That wouldn't be very good if the site was blocked now would it?
- Previously, everything we did wasn't encrypted. This allowed a site owner to hijack the requests and see everything on the server. (Kinda scary isn't it?).
- NOTE: this is a very simple explanation. It is NOT all extensive
Implementing Ultraviolet.
- Ok enough explaning, let's get to adding Ultraviolet!
- First, we need to add some things to our
index.js
file. Add these lines to the file below where we serve our public folder:
import { uvPath } from "@titaniumnetwork-dev/ultraviolet"; // ADD THIS TO THE TOP OF THE FILE!!!!
// "/uv/" is where the uv files will be available from. uvPath is just where those files are located
app.use("/uv/", express.static(uvPath));
The final code should look like this:
//Import express, http and wisp
import http from 'node:http';
import express from 'express';
import wisp from 'wisp-server-node';
import path from 'node:path';
import { uvPath } from "@titaniumnetwork-dev/ultraviolet";
//create the express "app"
const app = express();
//create an http server
const httpServer = http.createServer();
//define the port to listen on
//change this to your liking!
const port = 8080;
app.use(express.static(path.join(import.meta.dirname, "public" /* This is the folder you created with the index.html file in it */)));
// "/uv/" is where the uv files will be available from. uvPath is just where those files are located
app.use("/uv/", express.static(uvPath));
//listen for requests on the http server.
httpServer.on('request', (req, res) => {
//make express handle all of the requests
app(req, res)
});
//listen for websocket upgrades on the http server
httpServer.on('upgrade', (req, socket, head) => {
if (req.url.endsWith('/wisp/')) {
//route the request to the wisp server if the url ends in /wisp/
wisp.routeRequest(req, socket, head);
}
else {
socket.end();
}
});
//when the server is ready, console.log that it is ready
httpServer.on('listening', () => {
console.log(`Server listening on http://localhost:${port}`);
});
//start the http server
httpServer.listen({
port: port
});
- Now we need to create one new folder called
uv
(it has to be named UV!) in ourpublic
folder. Example with the terminal in Linux:
mkdir public/uv/
- Now, let's add a file in that folder called
uv.config.js
Example using Linux & the terminal:
touch public/uv/uv.config.js
- Add the following content to that file:
self.__uv$config = {
// The prefix is where we "intercept" the request.
prefix: "/uv/service/",
//where Ultraviolet encodes & decodes the urls.
encodeUrl: Ultraviolet.codec.xor.encode,
decodeUrl: Ultraviolet.codec.xor.decode,
//extra UV stuff
handler: "/uv/uv.handler.js",
client: "/uv/uv.client.js",
bundle: "/uv/uv.bundle.js",
config: "/uv/uv.config.js",
sw: "/uv/uv.sw.js",
};
- Add the following code to the
<head>
of yourindex.html
file:
<!-- The UV bundle. It contains most of the code for ultraviolet to function properly -->
<script src="uv/uv.bundle.js" defer></script>
<!-- The uv.config.js file we just made. -->
<script src="uv/uv.config.js" defer></script>
- Now we need to register Ultraviolet in a service worker. Let's create another file called sw.js in our
public
folder. Example with the terminal in Linux:
touch public/sw.js
- Now, let's edit that file and add the following content:
//The UV bundle. It contains most of the code for ultraviolet to function properly.
importScripts('/uv/uv.bundle.js');
//our uv.config.js that we just made a few steps ago
importScripts('/uv/uv.config.js');
//the actual Ultraviolet service worker. Needed for UV to function properly.
importScripts(__uv$config.sw || '/uv/uv.sw.js');
//create the uv service worker
const uv = new UVServiceWorker();
//listen for when things are requested.
self.addEventListener('fetch', function (event) {
//If the request starts with the websites origin (eg. https://localhost:8080) and the uv prefix (/uv/service), then proxy the request.
if (event.request.url.startsWith(location.origin + __uv$config.prefix)) {
//respond (proxy) the request
event.respondWith(
(async function () {
return await uv.fetch(event);
})()
);
}
//if it doesn't start with the origin and prefix, just get the stuff normally.
else {
event.respondWith(
(async function() {
return await fetch(event.request);
})()
);
}
});
- In your
index.html
file add this to the bottom of the<body>
to get the service worker to register:
<script>
async function regSW() {
//if the service worker doesn't exist throw an error
if (!navigator.serviceWorker) {
throw new Error("Your browser doesn't support service workers.");
}
//register the service worker. (We just made that file!)
await navigator.serviceWorker.register("/sw.js");
}
regSW();
</script>
- CHALLENGE: extract the code above into it's own file!
- Voila! Ultraviolet is now added and working (ish). Let's move onto the next step.
Setting up Bare Mux
Explanation
Setup
- What is Bare Mux?
- Bare Mux allows us to set and use a "transport" (this will be explained later) in Ultraviolet.
- Edit your
index.js
to include the following:
//IMPORT THIS AT THE TOP OF THE FILE:
import { baremuxPath } from "@mercuryworkshop/bare-mux/node"; //Note how we are using /node ath the end of this import. This provides correct types when using TypeScript
//
// "/baremux/" is where the bare-mux files will be available from. bareMuxPath is just where those files are located.
app.use("/baremux/", express.static(baremuxPath));
Your full file should look something like this:
//Import express, http and wisp
import http from 'node:http';
import express from 'express';
import wisp from 'wisp-server-node';
import path from 'node:path';
import { uvPath } from "@titaniumnetwork-dev/ultraviolet";
import { baremuxPath } from "@mercuryworkshop/bare-mux/node"; //Note how we are using /node at the end of this import. This provides the correct types when using TypeScript.
//create the express "app"
const app = express();
//create an http server
const httpServer = http.createServer();
//define the port to listen on
//change this to your liking!
const port = 8080;
app.use(express.static(path.join(import.meta.dirname, "public" /* This is the folder you created with the index.html file in it */)));
// "/uv/" is where the uv files will be available from. uvPath is just where those files are located
app.use("/uv/", express.static(uvPath));
// "/baremux/" is where the bare-mux files will be available from. baremuxPath is just where those files are located
app.use("/baremux/", express.static(baremuxPath));
//listen for requests on the http server.
httpServer.on('request', (req, res) => {
//make express handle all of the requests
app(req, res)
});
//listen for websocket upgrades on the http server
httpServer.on('upgrade', (req, socket, head) => {
if (req.url.endsWith('/wisp/')) {
//route the request to the wisp server if the url ends in /wisp/
wisp.routeRequest(req, socket, head);
}
else {
socket.end();
}
});
//when the server is ready, console.log that it is ready
httpServer.on('listening', () => {
console.log(`Server listening on http://localhost:${port}`);
});
//start the http server
httpServer.listen({
port: port
});
- Add the following to the
<head>
tag:
<!-- BareMux content -->
<script src="baremux/index.js" defer></script>
- Now, let's create the inital bare-mux connection. Remember that
<script>
tag at the bottom of the body in yourindex.html
? Add this to it:
//function for ease of use
async function setTransport() {
//create a new bare mux connection
const conn = new BareMux.BareMuxConnection("/baremux/worker.js")
}
- Voila! Bare Mux is now setup let's move onto the next step:
Setting up a Transport.
Explanation
- What is a "transport"?
- A "transport" in this sense is a program that allows Bare Mux to "transport" a request to the server. Most of them, allow you to encrypt traffic (via TLS using WASM and utilizes Wisp.
- There are currently at the time of writing 3 transports:
- Epoxy (Smallest size, fast, mostly stable, what we will be using).
- libcurl.js (Slightly better browser compatibility (Firefox), more features)
- And Bare as Module 3 (Legacy, unencrypted, doesn't use Wisp, NOT RECOMMENDED)
- We are going to use Epoxy
Setup:
- Edit your
index.js
with the following:
//IMPORT THIS AT THE TOP OF THE FILE:
import { epoxyPath } from "@mercuryworkshop/epoxy-transport";
//
// "/epoxy/" is where the epoxy files will be served from. epoxyPath is just the location to those files.
app.use("/epoxy/", express.static(epoxyPath));
The final code should looks something like this:
//Import express, http and wisp
import http from 'node:http';
import express from 'express';
import wisp from 'wisp-server-node';
import path from 'node:path';
import { uvPath } from "@titaniumnetwork-dev/ultraviolet";
import { baremuxPath } from "@mercuryworkshop/bare-mux/node"; //Note how we are using /node at the end of this import. This provides the correct types when using TypeScript.
import { epoxyPath } from "@mercuryworkshop/epoxy-transport";
//create the express "app"
const app = express();
//create an http server
const httpServer = http.createServer();
//define the port to listen on
//change this to your liking!
const port = 8080;
app.use(express.static(path.join(import.meta.dirname, "public" /* This is the folder you created with the index.html file in it */)));
// "/uv/" is where the uv files will be available from. uvPath is just where those files are located
app.use("/uv/", express.static(uvPath));
// "/baremux/" is where the bare-mux files will be available from. baremuxPath is just where those files are located
app.use("/baremux/", express.static(baremuxPath));
// "/epoxy/" is where the epoxy files will be served from. epoxyPath is just the location to those files.
app.use("/epoxy/", express.static(epoxyPath));
//listen for requests on the http server.
httpServer.on('request', (req, res) => {
//make express handle all of the requests
app(req, res)
});
//listen for websocket upgrades on the http server
httpServer.on('upgrade', (req, socket, head) => {
if (req.url.endsWith('/wisp/')) {
//route the request to the wisp server if the url ends in /wisp/
wisp.routeRequest(req, socket, head);
}
else {
socket.end();
}
});
//when the server is ready, console.log that it is ready
httpServer.on('listening', () => {
console.log(`Server listening on http://localhost:${port}`);
});
//start the http server
httpServer.listen({
port: port
});
- Remember that function called
setTransport
? Let's actually set a transport! Add the following lines in that function:
//If you are using http:// change it to ws:// or if using https:// change it to wss://, get the domain name and add "/wisp/" to the end of it
const wispUrl = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/wisp/";
//actually set the transport!!
await conn.setTransport("/epoxy/index.mjs", [{ wisp: wispUrl /* We just set this url! */ }]);
The final function should look something like this:
async function setTransport() {
//create a new bare mux connection
const conn = new BareMux.BareMuxConnection("/baremux/worker.js");
//If you are using http:// change it to ws:// or if using https:// change it to wss://, get the domain name and add "/wisp/" to the end of it
const wispUrl = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/wisp/";
//actually set the transport!!
await conn.setTransport("/epoxy/index.mjs", [{ wisp: wispUrl /* We just set this url! */ }]);
}
The final <script>
tag should look like this:
<script>
async function regSW() {
//if the service worker doesn't exist throw an error
if (!navigator.serviceWorker) {
throw new Error("Your browser doesn't support service workers.");
}
//register the service worker. (We just made that file!)
await navigator.serviceWorker.register("/sw.js");
}
regSW();
//function for ease of use
async function setTransport() {
//create a new bare mux connection
const conn = new BareMux.BareMuxConnection("/baremux/worker.js");
//If you are using http:// change it to ws:// or if using https:// change it to wss://, get the domain name and add "/wisp/" to the end of it
const wispUrl = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/wisp/";
//actually set the transport!!
await conn.setTransport("/epoxy/index.mjs", [{ wisp: wispUrl /* We just set this url! */ }]);
}
</script>
- CHALLENGE: extract those functions into their own files!
- Phew that's a lot of stuff to do! Let's move on to the final step:
Using Ultraviolet
- Let's create a new function in our
<script>
tag calledproxy
//new function, pass in the url so we can actually proxy it!
async function proxy(url) {
//get the iframe!
const iframe = document.getElementById("frame");
//create the initial url with ultraviolet
const uvUrl = __uv$config.prefix /* The prefix set in the config */ + __uv$config.encodeUrl(url) /* Encode the url with XOR */
//call our setTransport function
await setTransport();
//remove the "dnone" class so the iframe is visible.
iframe.classList.remove("dnone");
//set the iframe's source to the initial url
iframe.src = uvUrl;
}
- Now, in the same
<script>
tag we need to listen for when the user hit's enter on our search box!
//listen for keypresses in the address bar
document.getElementById("address").addEventListener("keypress", function (event) {
//if it's the enter key, proxy the url!
if (event.key === "Enter") {
//call our proxy function with the url they entered
proxy(document.getElementById("address").value /* The value the user has entered */)
}
})
- After adding this, you might notice that the user has to enter a full url instead of just being able to search something. So, how do we fix this? We add another function of course. Let's call it
search
.
//new function to allow a user to be able to search intead of having to type in a full url
function search(key /* the user's value */, template /* the search engine template to use */) {
try {
//if the entered a full url! Continue on
return new URL(key).toString();
} catch (error) { /* ignore errors */ }
try {
//if the entered value is a full URL when adding http:// or https:// in front of it, add http:// and the continue.
const url = new URL(`http://${key}`);
//we also have to make sure it is an actual domain!
if (url.hostname.includes('.')) return url.toString();
} catch (error) { /* Ignore the errors */ }
//if the above doesn't pass, add the entered value to a search template and the continue.
return template.replace("%s", encodeURIComponent(key));
}
- Now let's make that function work with our proxy function! Change this line from:
const uvUrl = __uv$config.prefix /* The prefix set in the config */ + __uv$config.encodeUrl(url) /* Encode the url with XOR */
To:
const uvUrl = __uv$config.prefix /* The prefix set in the config */ + __uv$config.encodeUrl(/* Our search function! */ search(url, "https://www.google.com/search?q=%s" /* the search engine template. Feel free to change it to whatever search engine you want (just make sure to add %s add the end!) */)) /* Encode the url with XOR */
- Now the user can search without having to enter a full URL!
Ending
- phew! That was tons and tons of steps wasn't it? Let's recap what we did:
- We created a basic frontend using only basic HTML & some CSS
- We installed, explained and added Ultraviolet.
- We setup Bare Mux & properly set the transport.
- We used Ultraviolet & setup some cool extra things (like our
search
function) for a better use experience.
- With the knowledge you gained from this, I hope you go out and make something cool with it!
- I have made the Example Repo a template! All you have to do is click the "Use this template button"!
- The part 2 files can be located here