mirror of
https://github.com/MercuryWorkshop/epoxy-tls.git
synced 2025-05-12 14:00: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