add client showcase

This commit is contained in:
Toshit Chawda 2025-01-29 21:27:24 -08:00
parent 3a7946a05b
commit bc9d180aaa
No known key found for this signature in database
GPG key ID: 91480ED99E2B3D9D
13 changed files with 848 additions and 0 deletions

24
client-showcase/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>epoxy-tls: TLS + HTTP + WebSockets in WASM</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,21 @@
{
"name": "client-showcase",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.7.3",
"vite": "^6.0.11",
"vite-plugin-dreamland": "^1.2.1",
"vite-plugin-static-copy": "^2.2.0"
},
"dependencies": {
"@mercuryworkshop/epoxy-tls": "file:../client",
"dreamland": "^0.0.25"
}
}

View file

@ -0,0 +1,351 @@
import { fetch as epoxyFetch } from "./epoxy";
import { logger } from "./loggingws";
import { settings } from "./store";
const REQUEST_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"];
const REQUEST_BODY_METHODS = ["POST", "PUT", "CONNECT", "PATCH"];
type EpoxyRequest = {
method: string,
url: string,
headers: Record<string, string>,
body: string,
};
type EpoxyResponse = {
status: number,
statusText: string,
headers: Record<string, string>,
body: string,
}
async function fetchWithEpoxy(req: EpoxyRequest): Promise<EpoxyResponse> {
const extra: any = { method: req.method, headers: req.headers, };
if (req.body) {
extra.body = req.body;
}
const res = await epoxyFetch(req.url, extra);
return {
status: res.status,
statusText: res.statusText,
/* @ts-ignore */
headers: res.rawHeaders,
body: await res.text(),
};
}
const Option: Component<{ value: string }> = function() {
return <option value={this.value}>{this.value}</option>
}
const Textarea: Component<{ value: string, id: string, placeholder: string }> = function() {
return <textarea
spellcheck="false"
autocomplete="off"
autocapitalize="off"
id={this.id}
placeholder={this.placeholder}
bind:value={use(this.value)}
/>
}
const RequestBuilder: Component<{
req: EpoxyRequest,
area: string,
}, {
method: string,
url: string,
headers: string,
body: string,
}> = function() {
this.css = `
background: var(--surface1);
padding: 1rem;
font-family: monospace;
display: flex;
flex-direction: column;
gap: 0.5rem;
.first-line {
display: flex;
gap: 0.5rem;
align-items: center;
}
.first-line .url {
flex: 1;
}
.first-line .method, .first-line .url {
background: transparent;
color: var(--fg);
font-family: monospace;
border: none;
}
.first-line .method option {
background: var(--surface2);
border: none;
}
textarea {
background: transparent;
color: var(--fg);
border: none;
resize: none;
flex: 1;
}
#body {
border-top: 2px solid var(--surface6);
}
`;
this.method = "GET";
this.url = "https://example.com/";
this.headers = [
"X-Goog-Spatula: qwertyuiop",
].join("\n");
this.body = "";
useChange([this.method, this.url, this.headers, this.body, settings.wispServer], () => {
const headers = this.headers.trim()
.split("\n")
.map(x => x.split(": "))
.filter(x => x.length >= 2)
.map(x => [x[0], x.slice(1).join(": ")]);
this.req = {
method: this.method,
url: this.url,
headers: Object.fromEntries(headers),
body: this.body,
}
});
return (
<div area={this.area}>
<div class="first-line">
Wisp Server:
<input type="url" spellcheck="false" autocomplete="off" autocapitalize="off" class="url" id="wisp" bind:value={use(settings.wispServer)} />
</div>
<div class="first-line">
<select class="method" id="method" bind:value={use(this.method)}>
{REQUEST_METHODS.map(x => <Option value={x} />)}
</select>
<input type="url" spellcheck="false" autocomplete="off" autocapitalize="off" class="url" id="url" bind:value={use(this.url)} />
<span>HTTP/1.1</span>
</div>
<Textarea bind:value={use(this.headers)} id="headers" placeholder="Headers" />
{$if(use(this.method, x => REQUEST_BODY_METHODS.includes(x)),
<Textarea bind:value={use(this.body)} id="body" placeholder="Body" />
)}
</div>
);
}
const ResponseView: Component<{ response: EpoxyResponse }> = function() {
this.css = `
font-family: monospace;
display: flex;
flex-direction: column;
gap: 0.5rem;
.first-line {
display: flex;
gap: 0.5rem;
align-items: center;
}
.body {
white-space: pre-wrap;
}
.divider {
height: 0.5rem;
}
.warning {
font-family: sans-serif;
color: var(--error);
}
`;
const corsHeader = Object.entries(this.response.headers).find(x => x[0] === "access-control-allow-origin");
const corsFailed = !corsHeader || !corsHeader[1].includes("*");
return (
<div>
{corsFailed ? <div class="warning">This request would have failed with native fetch</div> : null}
<div class="first-line">
<span>HTTP/1.1</span>
<span>{this.response.status}</span>
<span>{this.response.statusText}</span>
</div>
<div class="headers">
{Object.entries(this.response.headers).map(([k, v]) => {
return <div>{k}: {v}</div>;
})}
</div>
<div class="divider" />
<div class="body">
{this.response.body}
</div>
</div>
)
}
const RequestSender: Component<{ req: EpoxyRequest, area: string }, {
loading: boolean,
id: number,
response: EpoxyResponse | null,
error: Error | null,
}> = function() {
this.css = `
background: var(--surface1);
padding: 1rem;
overflow-y: scroll;
.error {
font-family: monospace;
}
`;
this.id = 0;
this.loading = false;
this.response = null;
this.error = null;
const sendReq = async (req: EpoxyRequest, id: number) => {
try {
const res = await fetchWithEpoxy(req);
if (this.id === id) {
this.response = res;
console.debug("successfully fetched:", req, res);
return true;
}
} catch (err) {
if (this.id === id) {
this.error = err as Error;
console.log("error while fetching:", req, err);
return true;
}
}
return false;
};
useChange([this.req], async () => {
this.loading = true;
this.response = null;
this.error = null;
const id = ++this.id;
if (await sendReq(this.req, id)) {
this.loading = false;
}
});
return (
<div area={this.area}>
{$if(use(this.loading),
<div>
Loading...
</div>
)}
{use(this.response, x => x ? <ResponseView response={x!} /> : null)}
{use(this.error, x => x ? <div class="error">{x}</div> : null)}
</div>
)
}
const WebsocketLogger: Component<{ area: string }, {}> = function() {
this.css = `
display: flex;
flex-direction: column;
overflow-y: scroll;
overflow-x: hidden;
max-height: 100%;
gap: 4px;
font-family: monospace;
background-color: var(--surface0);
.message {
display: grid;
grid-template-columns: 2rem calc(50% - 2rem - 6px) 50%;
align-items: stretch;
gap: 4px;
min-width: 0;
}
.type {
display: flex;
justify-content: center;
padding-top: 0.25rem;
}
.type-rx {
background: var(--success);
color: var(--bg);
}
.type-tx {
background: var(--error);
color: var(--bg);
}
.payload {
white-space: pre-wrap;
padding: 0.5rem;
background-color: var(--surface1);
}
.text {
line-break: anywhere;
}
`;
return (
<div area={this.area}>
{use(logger.logged, x => {
let ret = x.map(x => {
return (
<div class="message">
<div class={`type type-${x.type}`}>
{x.type}
</div>
<div class="payload binary">
{x.hex}
</div>
<div class="payload text">
{x.text}
</div>
</div>
)
});
return ret;
})}
</div>
);
}
export const Demo: Component<{}, {
req: EpoxyRequest,
}> = function() {
this.css = `
display: grid;
grid-template-areas:
"a b"
"c c";
grid-auto-rows: 24rem;
grid-auto-columns: 1fr 1fr;
gap: 4px;
div[area="a"] { grid-area: a; }
div[area="b"] { grid-area: b; }
div[area="c"] { grid-area: c; }
`;
return (
<div>
<RequestBuilder bind:req={use(this.req)} area="a" />
<RequestSender req={use(this.req)} area="b" />
<WebsocketLogger area="c" />
</div>
)
}

View file

@ -0,0 +1,72 @@
import epoxyInit, { EpoxyClient, EpoxyClientOptions, info as epoxyInfo } from "@mercuryworkshop/epoxy-tls/minimal-epoxy";
import { settings } from "./store";
import { WebSocketStream } from "./loggingws";
export let epoxyVersion = epoxyInfo.version;
const EPOXY_PATH = "/epoxy/epoxy.wasm";
let cache: Cache = await window.caches.open("epoxy");
let initted: boolean = false;
let currentClient: EpoxyClient;
let currentWispUrl: string;
async function evictEpoxy() {
await cache.delete(EPOXY_PATH);
}
async function instantiateEpoxy() {
if (!await cache.match(EPOXY_PATH)) {
await cache.add(EPOXY_PATH);
}
const module = await cache.match(EPOXY_PATH);
await epoxyInit({ module_or_path: module });
initted = true;
}
export async function createEpoxy() {
let options = new EpoxyClientOptions();
options.user_agent = navigator.userAgent;
options.udp_extension_required = false;
currentWispUrl = settings.wispServer;
// @ts-ignore TODO fix types
currentClient = new EpoxyClient(async () => {
try {
const wss = new WebSocketStream(settings.wispServer);
const ws = await wss.opened;
return { read: ws.readable, write: ws.writable };
} catch {
throw new Error("Failed to connect to Wisp Server: " + settings.wispServer);
}
}, options);
}
export async function fetch(url: string, options?: any): Promise<Response> {
if (!initted) {
if (epoxyVersion === settings.epoxyVersion) {
await instantiateEpoxy();
} else {
await evictEpoxy();
await instantiateEpoxy();
console.log(`evicted epoxy "${settings.epoxyVersion}" from cache because epoxy "${epoxyVersion}" is available`);
settings.epoxyVersion = epoxyVersion;
}
}
if (currentWispUrl !== settings.wispServer) {
await createEpoxy();
}
try {
return await currentClient.fetch(url, options);
} catch (err2) {
let err = err2 as Error;
console.log(err);
throw err;
}
}
// @ts-ignore
window.epoxyFetch = fetch;

View file

@ -0,0 +1,121 @@
// https://github.com/CarterLi/websocketstream-polyfill modified for logging
export type Packet = {
type: "rx" | "tx",
text: string,
hex: string,
}
export const logger: Stateful<{ logged: Packet[] }> = $state({ logged: [] });
function split<T>(arr: T[], len: number): T[][] {
const ret = [];
let i;
for (i = 0; i < arr.length; i += len) {
ret.push(arr.slice(i, i + len));
}
if (i != arr.length)
ret.push(arr.slice(i));
return ret;
}
function toHex(arr: ArrayBuffer): string {
return split([...new Uint8Array(arr)]
.map(x => x.toString(16).padStart(2, '0')), 2)
.map(x => x.join(''))
.join(' ');
}
function toText(arr: ArrayBuffer): string {
return [...new Uint8Array(arr)].map(x => String.fromCharCode(x)).join('');
}
export interface WebSocketConnection<T extends Uint8Array | string = Uint8Array | string> {
readable: ReadableStream<T>;
writable: WritableStream<T>;
protocol: string;
extensions: string;
}
export interface WebSocketCloseInfo {
closeCode?: number;
reason?: string;
}
export interface WebSocketStreamOptions {
protocols?: string[];
signal?: AbortSignal;
}
/**
* [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) with [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API)
*
* @see https://web.dev/websocketstream/
*/
export class WebSocketStream<T extends Uint8Array | string = Uint8Array | string> {
readonly url: string;
readonly opened: Promise<WebSocketConnection<T>>;
readonly closed: Promise<WebSocketCloseInfo>;
readonly close: (closeInfo?: WebSocketCloseInfo) => void;
constructor(url: string, options: WebSocketStreamOptions = {}) {
if (options.signal?.aborted) {
throw new DOMException('This operation was aborted', 'AbortError');
}
this.url = url;
const ws = new WebSocket(url, options.protocols ?? []);
ws.binaryType = "arraybuffer";
logger.logged = [];
const closeWithInfo = ({ closeCode: code, reason }: WebSocketCloseInfo = {}) => ws.close(code, reason);
this.opened = new Promise((resolve, reject) => {
ws.onopen = () => {
resolve({
readable: new ReadableStream<T>({
start(controller) {
ws.onmessage = ({ data }) => {
logger.logged = [...logger.logged, { type: "rx", text: toText(data), hex: toHex(data) }];
controller.enqueue(data);
}
ws.onerror = e => controller.error(e);
},
cancel: closeWithInfo,
}),
writable: new WritableStream<T>({
write(chunk) {
const data = chunk as ArrayBuffer;
logger.logged = [...logger.logged, { type: "tx", text: toText(data), hex: toHex(data) }];
ws.send(data);
},
abort() { ws.close(); },
close: closeWithInfo,
}),
protocol: ws.protocol,
extensions: ws.extensions,
});
ws.removeEventListener('error', reject);
};
ws.addEventListener('error', reject);
});
this.closed = new Promise<WebSocketCloseInfo>((resolve, reject) => {
ws.onclose = ({ code, reason }) => {
resolve({ closeCode: code, reason });
ws.removeEventListener('error', reject);
};
ws.addEventListener('error', reject);
});
if (options.signal) {
options.signal.onabort = () => ws.close();
}
this.close = closeWithInfo;
}
}

View file

@ -0,0 +1,94 @@
import 'dreamland';
import { settings } from './store';
import "./style.css";
import { Git, Link } from './util';
import { Demo } from './demo';
const Theme: Component<{}> = function() {
this.css = `
text-decoration: underline !important;
`;
return <a on:click={() => settings.theme = settings.theme === "light" ? "dark" : "light"} href="#"><i>{use(settings.theme)}</i></a>;
}
const App: Component<{}, {}> = function() {
this.css = `
background: var(--bg-sub);
color: var(--fg);
padding: 1rem;
overflow: scroll;
.root {
margin: auto;
padding: 1rem;
max-width: 64rem;
}
`;
return (
<div id="app" class={use(settings.theme)}>
<div class="root">
<h1><code>epoxy-tls</code></h1>
<div>
<Git repo="mercuryworkshop/epoxy-tls"><i>GitHub</i></Git>
{' | '}
<Link href="https://www.npmjs.com/package/@mercuryworkshop/epoxy-tls"><i>npm</i></Link>
{' | '}
<Link href="https://crates.io/crates/wisp-mux"><i>crates.io</i></Link>
{' | '}
<Theme />
</div>
<p>
A set of libraries and programs for securely bypassing CORS end-to-end-encrypted by running TLS in the browser through <Link href="https://webassembly.org"><i>WebAssembly</i></Link>.
The client is built in Rust with the <Git repo="rustls/rustls"><code>rustls</code></Git> TLS implementation and <Git repo="hyperium/hyper"><code>hyper</code></Git> HTTP implementation.
It uses the <Git repo="mercuryworkshop/wisp-protocol">Wisp protocol</Git> as a TCP proxy to connect to and communicate with remote servers.
</p>
<h2>Demo</h2>
<Demo />
<h2>Features</h2>
<ul>
<li>A blazingly fast Wisp server: <code>epoxy-server</code></li>
<ul>
<li>As of January 2025, the fastest Wisp server measured in <Git repo="mercuryworkshop/wispmark">Wispmark</Git>, a benchmarking tool for Wisp servers, achieving <b>4.3GiB/s throughput</b> with 5 clients connecting with 10 streams each</li>
<li>Very configurable, with support for speed limits, domain blacklist/whitelist, statistics over HTTP, and listening on TCP, Unix sockets, and files all in a single TOML/JSON/YAML file</li>
<li>Has robust logging, showing every client connected and every stream created</li>
<li>Supports the <Git repo="mercuryworkshop/wispnet-protocol/tree/epoxy-server">WispNet</Git> protocol extension, allowing for peer-to-peer communication over Wisp</li>
<li>Supports the <code>twisp</code> protocol extension, allowing for terminals to be multiplexed over Wisp</li>
</ul>
<li>A CORS bypassing fetch and WebSocket implementation for the web: <code>epoxy-client</code></li>
<ul>
<li>Very small: the WASM is 780K uncompressed and 304K compressed (minimal version)</li>
<li>Supports HTTP/1.1 and HTTP/2 for increased performance</li>
<li>Supports WebSockets through the <Git repo="denoland/fastwebsockets"><i>fastwebsockets</i></Git> library</li>
<li>Supports HTTP/2 full-duplex, allowing for streaming requests and responses at the same time</li>
</ul>
<li>A Rust crate implementing the Wisp protocol</li>
<ul>
<li>Fully async, benefitting from the IO optimizations of popular async runtimes</li>
<li>Built to be runtime and IO agnostic, allowing for use in non-Tokio environments</li>
<li>Multiplexed streams implement the familiar <Link href="https://docs.rs/futures"><i>futures</i></Link> IO traits like <code>Stream&lt;Item = Bytes&gt; + Sink&lt;Bytes&gt;</code> and <code>AsyncRead + AsyncWrite</code></li>
<ul>
<li>This allows for multiplexed streams to be easily plugged into other popular crates like <Link href="https://docs.rs/hyper"><code>hyper</code></Link> and <Link href="https://docs.rs/tokio-tungstenite"><code>async-tungstenite</code></Link></li>
</ul>
<li>Supports custom protocol extensions, allowing you to quickly add on authentication and other features to the existing Wisp protocol</li>
</ul>
</ul>
<h2>Licenses</h2>
<ul>
<li><code>epoxy-client</code>: AGPL</li>
<li><code>epoxy-server</code>: AGPL</li>
<li><code>wisp-mux</code>: MIT</li>
</ul>
</div>
</div>
);
}
const root = document.getElementById("app")!;
try {
root.replaceWith(<App />);
} catch (err) {
root.replaceWith(document.createTextNode(`Error while rendering: ${err}`));
}

View file

@ -0,0 +1,14 @@
export const settings: Stateful<{
epoxyVersion: string,
wispServer: string,
theme: "light" | "dark",
}> = $store({
epoxyVersion: "",
wispServer: "wss://wisp.mercurywork.shop/",
theme: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
}, { ident: "epoxy-showcase-settings", backing: "localstorage", autosave: "auto" })
// @ts-ignore
window.settings = settings;

View file

@ -0,0 +1,76 @@
body,
html {
padding: 0;
margin: 0;
width: 100vw;
height: 100vh;
}
* {
outline-color: transparent;
outline: none;
box-sizing: border-box;
}
#app {
font-family: sans-serif;
min-height: 100vh;
}
#app.dark {
--bg-sub: #131313;
--bg: #171717;
--surface0: #1e1e1e;
--surface1: #2b2b2b;
--surface2: #333333;
--surface3: #3b3b3b;
--surface4: #444444;
--surface5: #4c4c4c;
--surface6: #555555;
--link: #7c98f6;
--error: #f02424;
--success: #84f084;
--fg: #f0f0f0;
--fg2: #e0e0e0;
--fg3: #d0d0d0;
--fg4: #c0c0c0;
--fg5: #b0b0b0;
--fg6: #a0a0a0;
}
#app.light {
--bg-sub: #fafafa;
--bg: #f0f0f0;
--surface0: #e0e0e0;
--surface1: #d0d0d0;
--surface2: #c0c0c0;
--surface3: #b0b0b0;
--surface4: #a0a0a0;
--surface5: #909090;
--surface6: #808080;
--link: #6097f0;
--error: #e43e3e;
--success: #14b614;
--fg: #171717;
--fg2: #2b2b2b;
--fg3: #3b3b3b;
--fg4: #4c4c4c;
--fg5: #555555;
--fg6: #5f5f5f;
}
h1 {
margin: 0.5rem 0;
}
a {
color: var(--link);
}
#app.light a:hover {
color: color-mix(in srgb, var(--link), #000000 50%);
}
#app.dark a:hover {
color: color-mix(in srgb, var(--link), #ffffff 50%);
}

View file

@ -0,0 +1,10 @@
export const Link: Component<{ href: string }, { children: any }> = function() {
this.css = `
text-decoration: underline !important;
`;
return <a href={this.href} rel="noopener noreferrer" target="_blank">{this.children}</a>
}
export const Git: Component<{ repo: string }, { children: any }> = function() {
return <span><Link href={`https://github.com/${this.repo}`}>{this.children}</Link></span>;
}

1
client-showcase/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"types": [
"dreamland"
]
},
"include": [
"src"
]
}

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import { dreamlandPlugin } from 'vite-plugin-dreamland';
import { viteStaticCopy } from 'vite-plugin-static-copy';
export default defineConfig({
plugins: [dreamlandPlugin(), viteStaticCopy({
targets: [
{ src: "node_modules/@mercuryworkshop/epoxy-tls/minimal/epoxy.wasm", dest: "epoxy" },
]
})],
base: './'
});