mirror of
https://github.com/MercuryWorkshop/epoxy-tls.git
synced 2025-05-12 05:50:01 -04:00
add client showcase
This commit is contained in:
parent
3a7946a05b
commit
bc9d180aaa
13 changed files with 848 additions and 0 deletions
24
client-showcase/.gitignore
vendored
Normal file
24
client-showcase/.gitignore
vendored
Normal 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?
|
15
client-showcase/index.html
Normal file
15
client-showcase/index.html
Normal 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>
|
21
client-showcase/package.json
Normal file
21
client-showcase/package.json
Normal 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"
|
||||
}
|
||||
}
|
351
client-showcase/src/demo.tsx
Normal file
351
client-showcase/src/demo.tsx
Normal 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>
|
||||
)
|
||||
}
|
72
client-showcase/src/epoxy.ts
Normal file
72
client-showcase/src/epoxy.ts
Normal 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;
|
121
client-showcase/src/loggingws.ts
Normal file
121
client-showcase/src/loggingws.ts
Normal 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;
|
||||
}
|
||||
}
|
94
client-showcase/src/main.tsx
Normal file
94
client-showcase/src/main.tsx
Normal 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<Item = Bytes> + Sink<Bytes></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}`));
|
||||
}
|
14
client-showcase/src/store.ts
Normal file
14
client-showcase/src/store.ts
Normal 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;
|
76
client-showcase/src/style.css
Normal file
76
client-showcase/src/style.css
Normal 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%);
|
||||
}
|
10
client-showcase/src/util.tsx
Normal file
10
client-showcase/src/util.tsx
Normal 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
1
client-showcase/src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
37
client-showcase/tsconfig.json
Normal file
37
client-showcase/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
12
client-showcase/vite.config.ts
Normal file
12
client-showcase/vite.config.ts
Normal 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: './'
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue