From 66185cb32f16f67e6fdba2bfd788b1d1d6fa2263 Mon Sep 17 00:00:00 2001 From: Marcos Candeia Date: Wed, 29 May 2024 07:18:43 -0300 Subject: [PATCH] Forked from mcandeia/warp Signed-off-by: Marcos Candeia --- .github/workflows/publish.yaml | 19 ++ README.md | 129 ++++++++- channel.ts | 121 ++++++++ client.ts | 81 ++++++ deno.json | 8 + deno.lock | 516 +++++++++++++++++++++++++++++++++ handlers.client.ts | 195 +++++++++++++ handlers.server.ts | 188 ++++++++++++ messages.ts | 95 ++++++ mod.ts | 5 + notify.ts | 93 ++++++ queue.ts | 72 +++++ server.ts | 116 ++++++++ test.ts | 72 +++++ 14 files changed, 1708 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish.yaml create mode 100644 channel.ts create mode 100644 client.ts create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 handlers.client.ts create mode 100644 handlers.server.ts create mode 100644 messages.ts create mode 100644 mod.ts create mode 100644 notify.ts create mode 100644 queue.ts create mode 100644 server.ts create mode 100644 test.ts diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..ad5781d --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,19 @@ +name: Publish +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: Publish package + run: npx jsr publish diff --git a/README.md b/README.md index 5b0c6cf..bf95b16 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,127 @@ -# warp -A simple tool that allows your locally running HTTP(s) servers to have a public URL, serving as an easy-to-self-host alternative to services like `ngrok` +# Warp + +**Warp** is a simple tool that allows your locally running HTTP(s) servers to have a public URL, serving as an easy-to-self-host alternative to services like `ngrok`. Warp is implemented in Deno with the goal of providing flexibility and minimal dependencies. + +The project has two main components: + +- **Server**: Deployable on a server, it connects to the outside world and is accessible from any domain. +- **Client**: Runs locally to connect a given HTTP endpoint running on a local or non-public network. + +## Server + +The Warp server opens a single HTTP port to which the Warp client connects and upgrades to a WebSocket connection. Each request to this HTTP port is forwarded (based on the client's HOST header) to the corresponding connected Warp client connection, which then serves the request. + +### Usage + +To start the Warp server, import the `start` function from the Warp package and call it with the appropriate configuration. + +#### Example + +```typescript +import { start } from "jsr:@mcandeia/warp"; + +const port = 8080; // The port where the Warp server will listen +const apiKeys = ["YOUR_API_KEY1", "YOUR_API_KEY2"]; // Array of API keys for authentication + +start({ port, apiKeys }); +``` + +#### Parameters + +- `port`: The port number where the Warp server will listen for connections. +- `apiKeys`: An array of API keys used for client authentication. + +## Client + +The Warp client connects to the Warp server. Upon connection, the client shares the given API key and the domain it wants to receive requests for. + +### Usage + +To connect a client to the Warp server, import the `connect` function from the Warp package and call it with the appropriate configuration. + +#### Example + +```typescript +import { connect } from "jsr:@mcandeia/warp"; + +const port = 3000; // The local port you want to expose +const domain = "www.your.domain.com"; // The domain name for your service +const server = "wss://YOUR_SERVER"; // The WebSocket URL of your Warp server +const token = "YOUR_TOKEN"; // The authentication token + +const { registered, closed } = await connect({ + domain, + localAddr: `http://localhost:${port}`, + server, + token, +}); + +await registered; +console.log("Client registered successfully"); + +closed.then(() => { + console.log("Connection closed"); +}); +``` + +#### Parameters + +- `domain`: The domain name that will be used to access your localhost service. +- `localAddr`: The local address of the service you want to expose (e.g., `http://localhost:3000`). +- `server`: The WebSocket URL of your Warp server (e.g., `wss://YOUR_SERVER`). +- `token`: The authentication token for connecting to the Warp server. + +#### Return Values + +- `registered`: A promise that resolves when the client has successfully registered with the server. +- `closed`: A promise that resolves when the connection to the server is closed. + +## Example Workflow + +Here’s a complete example of setting up a Warp server and client: + +### Server + +```typescript +import { start } from "jsr:@mcandeia/warp"; + +const port = 8080; +const apiKeys = ["YOUR_API_KEY1", "YOUR_API_KEY2"]; + +start({ port, apiKeys }); +``` + +### Client + +```typescript +import { connect } from "jsr:@mcandeia/warp"; + +const port = 3000; +const domain = "www.your.domain.com"; +const server = "wss://YOUR_SERVER"; +const token = "YOUR_TOKEN"; + +(async () => { + const { registered, closed } = await connect({ + domain, + localAddr: `http://localhost:${port}`, + server, + token, + }); + + await registered; + console.log("Client registered successfully"); + + closed.then(() => { + console.log("Connection closed"); + }); +})(); +``` + +## Troubleshooting + +### Common Issues + +- **Invalid API Key**: Ensure that the API key you are using is listed in the `apiKeys` array on the server. +- **Connection Refused**: Check that the server is running and accessible at the specified WebSocket URL. +- **Domain Not Accessible**: Ensure that the domain name is correctly configured and pointing to the Warp server. diff --git a/channel.ts b/channel.ts new file mode 100644 index 0000000..5d92cdf --- /dev/null +++ b/channel.ts @@ -0,0 +1,121 @@ +import { Queue } from "./queue.ts"; + +export interface Channel { + close(): void; + send(value: T): Promise; + recv(): AsyncIterableIterator; +} + +export const makeChan = (): Channel => { + const queue: Queue<{ value: T, resolve: () => void }> = new Queue(); + const ctrl = new AbortController(); + const abortPromise = Promise.withResolvers(); + ctrl.signal.onabort = () => { + abortPromise.resolve(); + } + + const send = (value: T): Promise => { + return new Promise((resolve, reject) => { + if (ctrl.signal.aborted) reject(new Error("Channel is closed")); + queue.push({ value, resolve }); + }); + }; + + const close = () => { + ctrl.abort(); + }; + + const recv = async function* (): AsyncIterableIterator { + while (true) { + if (ctrl.signal.aborted) { + return; + } + try { + const next = await queue.pop({ signal: ctrl.signal }); + next.resolve(); + yield next.value; + } catch (_err) { + if (ctrl.signal.aborted) { + return; + } + throw _err; + } + } + }; + + return { send, recv, close }; +}; + +export interface DuplexChannel { + in: Channel + out: Channel +} + +export const makeWebSocket = (socket: WebSocket, parse: boolean = true): Promise> => { + const sendChan = makeChan(); + const recvChan = makeChan(); + const ch = Promise.withResolvers>(); + socket.onclose = () => { + sendChan.close(); + recvChan.close(); + } + socket.onerror = (err) => { + socket.close(); + ch.reject(err); + } + socket.onmessage = async (msg) => { + let eventData = msg.data; + const target = msg?.target; + if ( + target && "binaryType" in target && + target.binaryType === "blob" && typeof eventData === "object" && + "text" in eventData + ) { + eventData = await eventData.text(); + } + const message = parse ? JSON.parse(eventData) : eventData; + await recvChan.send(message); + } + socket.onopen = async () => { + ch.resolve({ in: recvChan, out: sendChan }); + for await (const message of sendChan.recv()) { + try { + socket.send(parse ? JSON.stringify(message) : message as ArrayBuffer); + } catch (_err) { + console.error("error sending message through socket", message); + } + } + socket.close(); + } + return ch.promise; +} + +export const makeReadableStream = (ch: Channel): ReadableStream => { + return new ReadableStream({ + async start(controller) { + for await (const content of ch.recv()) { + controller.enqueue(content); + } + controller.close(); + }, + cancel() { + ch.close(); + }, + }) +} +export const makeChanStream = (stream: ReadableStream): Channel => { + const chan = makeChan(); + + // Consume the transformed stream to trigger the pipeline + const reader = stream.getReader(); + const processStream = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + await chan.send(value); + } + chan.close(); + }; + processStream().catch(console.error); + return chan; +}; diff --git a/client.ts b/client.ts new file mode 100644 index 0000000..d74739d --- /dev/null +++ b/client.ts @@ -0,0 +1,81 @@ +import { type Channel, makeWebSocket } from "./channel.ts"; +import { handleServerMessage } from "./handlers.client.ts"; +import type { ClientMessage, ClientState, ServerMessage } from "./messages.ts"; + +/** + * Options for establishing a connection. + * @typedef {Object} ConnectOptions + * @property {string} token - The authentication token for connecting to the server. + * @property {string} domain - The domain to register the connection with. + * @property {string} server - The WebSocket server URL. + * @property {string} localAddr - The local address for the WebSocket connection. + */ +export interface ConnectOptions { + token: string; + domain: string; + server: string; + localAddr: string; +} + +/** + * Represents a connection status object. + * @typedef {Object} Connected + * @property {Promise} closed - A promise that resolves when the connection is closed. + * @property {Promise} registered - A promise that resolves when the connection is registered. + */ +export interface Connected { + closed: Promise; + registered: Promise; +} + +/** + * Establishes a WebSocket connection with the server. + * @param {ConnectOptions} opts - Options for establishing the connection. + * @returns {Promise} A promise that resolves with the connection status. + */ +export const connect = async (opts: ConnectOptions): Promise => { + const closed = Promise.withResolvers(); + const registered = Promise.withResolvers(); + const client = typeof Deno.createHttpClient === "function" ? Deno.createHttpClient({ + allowHost: true, + proxy: { + url: opts.localAddr, + } + }) : undefined; + + const socket = new WebSocket(`${opts.server}/_connect`); + const ch = await makeWebSocket(socket); + await ch.out.send({ + id: crypto.randomUUID(), + type: "register", + apiKey: opts.token, + domain: opts.domain, + }); + const requestBody: Record> = {}; + const wsMessages: Record> = {}; + + (async () => { + const state: ClientState = { + client, + localAddr: opts.localAddr, + live: false, + requestBody, + wsMessages, + ch, + } + for await (const message of ch.in.recv()) { + try { + await handleServerMessage(state, message); + if (state.live) { + registered.resolve(); + } + } catch (err) { + console.error(new Date(), "error handling message", err); + break; + } + } + closed.resolve(); + })() + return { closed: closed.promise, registered: registered.promise }; +} + diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..d3d8210 --- /dev/null +++ b/deno.json @@ -0,0 +1,8 @@ +{ + "name": "@deco/warp", + "version": "0.1.0", + "exports": "./mod.ts", + "tasks": { + "start": "deno run -A main.ts" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..b89119f --- /dev/null +++ b/deno.lock @@ -0,0 +1,516 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "npm:@types/node": "npm:@types/node@18.16.19", + "npm:punchmole": "npm:punchmole@1.1.0", + "npm:punchmole@1.1.1": "npm:punchmole@1.1.1", + "npm:ws": "npm:ws@8.16.0" + }, + "npm": { + "@types/node@18.16.19": { + "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", + "dependencies": {} + }, + "accepts@1.3.8": { + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "mime-types@2.1.35", + "negotiator": "negotiator@0.6.3" + } + }, + "array-flatten@1.1.1": { + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dependencies": {} + }, + "body-parser@1.20.2": { + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dependencies": { + "bytes": "bytes@3.1.2", + "content-type": "content-type@1.0.5", + "debug": "debug@2.6.9", + "depd": "depd@2.0.0", + "destroy": "destroy@1.2.0", + "http-errors": "http-errors@2.0.0", + "iconv-lite": "iconv-lite@0.4.24", + "on-finished": "on-finished@2.4.1", + "qs": "qs@6.11.0", + "raw-body": "raw-body@2.5.2", + "type-is": "type-is@1.6.18", + "unpipe": "unpipe@1.0.0" + } + }, + "bytes@3.1.2": { + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dependencies": {} + }, + "call-bind@1.0.7": { + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "es-define-property@1.0.0", + "es-errors": "es-errors@1.3.0", + "function-bind": "function-bind@1.1.2", + "get-intrinsic": "get-intrinsic@1.2.4", + "set-function-length": "set-function-length@1.2.2" + } + }, + "content-disposition@0.5.4": { + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "safe-buffer@5.2.1" + } + }, + "content-type@1.0.5": { + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dependencies": {} + }, + "cookie-signature@1.0.6": { + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dependencies": {} + }, + "cookie@0.6.0": { + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dependencies": {} + }, + "debug@2.6.9": { + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "ms@2.0.0" + } + }, + "define-data-property@1.1.4": { + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "es-define-property@1.0.0", + "es-errors": "es-errors@1.3.0", + "gopd": "gopd@1.0.1" + } + }, + "depd@2.0.0": { + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dependencies": {} + }, + "destroy@1.2.0": { + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dependencies": {} + }, + "dotenv@16.4.5": { + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dependencies": {} + }, + "ee-first@1.1.1": { + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dependencies": {} + }, + "encodeurl@1.0.2": { + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dependencies": {} + }, + "es-define-property@1.0.0": { + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "get-intrinsic@1.2.4" + } + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dependencies": {} + }, + "escape-html@1.0.3": { + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dependencies": {} + }, + "etag@1.8.1": { + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dependencies": {} + }, + "express@4.19.2": { + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dependencies": { + "accepts": "accepts@1.3.8", + "array-flatten": "array-flatten@1.1.1", + "body-parser": "body-parser@1.20.2", + "content-disposition": "content-disposition@0.5.4", + "content-type": "content-type@1.0.5", + "cookie": "cookie@0.6.0", + "cookie-signature": "cookie-signature@1.0.6", + "debug": "debug@2.6.9", + "depd": "depd@2.0.0", + "encodeurl": "encodeurl@1.0.2", + "escape-html": "escape-html@1.0.3", + "etag": "etag@1.8.1", + "finalhandler": "finalhandler@1.2.0", + "fresh": "fresh@0.5.2", + "http-errors": "http-errors@2.0.0", + "merge-descriptors": "merge-descriptors@1.0.1", + "methods": "methods@1.1.2", + "on-finished": "on-finished@2.4.1", + "parseurl": "parseurl@1.3.3", + "path-to-regexp": "path-to-regexp@0.1.7", + "proxy-addr": "proxy-addr@2.0.7", + "qs": "qs@6.11.0", + "range-parser": "range-parser@1.2.1", + "safe-buffer": "safe-buffer@5.2.1", + "send": "send@0.18.0", + "serve-static": "serve-static@1.15.0", + "setprototypeof": "setprototypeof@1.2.0", + "statuses": "statuses@2.0.1", + "type-is": "type-is@1.6.18", + "utils-merge": "utils-merge@1.0.1", + "vary": "vary@1.1.2" + } + }, + "finalhandler@1.2.0": { + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "debug@2.6.9", + "encodeurl": "encodeurl@1.0.2", + "escape-html": "escape-html@1.0.3", + "on-finished": "on-finished@2.4.1", + "parseurl": "parseurl@1.3.3", + "statuses": "statuses@2.0.1", + "unpipe": "unpipe@1.0.0" + } + }, + "forwarded@0.2.0": { + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dependencies": {} + }, + "fresh@0.5.2": { + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dependencies": {} + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dependencies": {} + }, + "get-intrinsic@1.2.4": { + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "es-errors@1.3.0", + "function-bind": "function-bind@1.1.2", + "has-proto": "has-proto@1.0.3", + "has-symbols": "has-symbols@1.0.3", + "hasown": "hasown@2.0.2" + } + }, + "gopd@1.0.1": { + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "get-intrinsic@1.2.4" + } + }, + "has-property-descriptors@1.0.2": { + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "es-define-property@1.0.0" + } + }, + "has-proto@1.0.3": { + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dependencies": {} + }, + "has-symbols@1.0.3": { + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dependencies": {} + }, + "hasown@2.0.2": { + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "function-bind@1.1.2" + } + }, + "http-errors@2.0.0": { + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "depd@2.0.0", + "inherits": "inherits@2.0.4", + "setprototypeof": "setprototypeof@1.2.0", + "statuses": "statuses@2.0.1", + "toidentifier": "toidentifier@1.0.1" + } + }, + "iconv-lite@0.4.24": { + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": "safer-buffer@2.1.2" + } + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dependencies": {} + }, + "ipaddr.js@1.9.1": { + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dependencies": {} + }, + "media-typer@0.3.0": { + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dependencies": {} + }, + "merge-descriptors@1.0.1": { + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dependencies": {} + }, + "methods@1.1.2": { + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dependencies": {} + }, + "mime-db@1.52.0": { + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dependencies": {} + }, + "mime-types@2.1.35": { + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "mime-db@1.52.0" + } + }, + "mime@1.6.0": { + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dependencies": {} + }, + "ms@2.0.0": { + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dependencies": {} + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dependencies": {} + }, + "negotiator@0.6.3": { + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dependencies": {} + }, + "object-inspect@1.13.1": { + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dependencies": {} + }, + "on-finished@2.4.1": { + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "ee-first@1.1.1" + } + }, + "parseurl@1.3.3": { + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dependencies": {} + }, + "path-to-regexp@0.1.7": { + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dependencies": {} + }, + "proxy-addr@2.0.7": { + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "forwarded@0.2.0", + "ipaddr.js": "ipaddr.js@1.9.1" + } + }, + "punchmole@1.1.0": { + "integrity": "sha512-e+8tSWpIsGjXmhArlGceRj3onlmYD6A/uYXM2857ygi3pNX2cu3XZY2kTu2VjkNi0QlflP3lPNAbxzZCZSUAIw==", + "dependencies": { + "dotenv": "dotenv@16.4.5", + "express": "express@4.19.2", + "ws": "ws@8.16.0" + } + }, + "punchmole@1.1.1": { + "integrity": "sha512-LnDMK1iJst7yPhTUjA3NOQZYAuG3IwrQUNEdufb0g230zI4YBTudEiqpswsHsgIC9zImpUvaaOFdw9mfQP9ksQ==", + "dependencies": { + "dotenv": "dotenv@16.4.5", + "express": "express@4.19.2", + "ws": "ws@8.16.0" + } + }, + "qs@6.11.0": { + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "side-channel@1.0.6" + } + }, + "range-parser@1.2.1": { + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dependencies": {} + }, + "raw-body@2.5.2": { + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "bytes@3.1.2", + "http-errors": "http-errors@2.0.0", + "iconv-lite": "iconv-lite@0.4.24", + "unpipe": "unpipe@1.0.0" + } + }, + "safe-buffer@5.2.1": { + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dependencies": {} + }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dependencies": {} + }, + "send@0.18.0": { + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "debug@2.6.9", + "depd": "depd@2.0.0", + "destroy": "destroy@1.2.0", + "encodeurl": "encodeurl@1.0.2", + "escape-html": "escape-html@1.0.3", + "etag": "etag@1.8.1", + "fresh": "fresh@0.5.2", + "http-errors": "http-errors@2.0.0", + "mime": "mime@1.6.0", + "ms": "ms@2.1.3", + "on-finished": "on-finished@2.4.1", + "range-parser": "range-parser@1.2.1", + "statuses": "statuses@2.0.1" + } + }, + "serve-static@1.15.0": { + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "encodeurl@1.0.2", + "escape-html": "escape-html@1.0.3", + "parseurl": "parseurl@1.3.3", + "send": "send@0.18.0" + } + }, + "set-function-length@1.2.2": { + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "define-data-property@1.1.4", + "es-errors": "es-errors@1.3.0", + "function-bind": "function-bind@1.1.2", + "get-intrinsic": "get-intrinsic@1.2.4", + "gopd": "gopd@1.0.1", + "has-property-descriptors": "has-property-descriptors@1.0.2" + } + }, + "setprototypeof@1.2.0": { + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dependencies": {} + }, + "side-channel@1.0.6": { + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "call-bind@1.0.7", + "es-errors": "es-errors@1.3.0", + "get-intrinsic": "get-intrinsic@1.2.4", + "object-inspect": "object-inspect@1.13.1" + } + }, + "statuses@2.0.1": { + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dependencies": {} + }, + "toidentifier@1.0.1": { + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dependencies": {} + }, + "type-is@1.6.18": { + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "media-typer@0.3.0", + "mime-types": "mime-types@2.1.35" + } + }, + "unpipe@1.0.0": { + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dependencies": {} + }, + "utils-merge@1.0.1": { + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dependencies": {} + }, + "vary@1.1.2": { + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dependencies": {} + }, + "ws@8.16.0": { + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "dependencies": {} + } + } + }, + "remote": { + "https://deno.land/x/async@v2.1.0/notify.ts": "8b4150ca1063586084825a34e997b845ccbe5014aed69327489e1fc89569ec0e", + "https://deno.land/x/async@v2.1.0/queue.ts": "8ef2e16aa4e257b4104b20adb945273b153a433dc0bdf0d6cc34af92f22735a5", + "https://denopkg.com/mcandeia/punchmole@02041f4a9087703e072879f99c6501ac90af9c6b/PunchmoleServer.ts": "09874b888aa5a8bf38d476886318a2a763b16076bc5a1ea7d1dfb0efc44b2845", + "https://esm.sh/gh/mcandeia/punchmole@c2246eaeae94451b913dc23113c28e79a4936406/PunchmoleServer.js": "d22cd42b180dd407d6f822b2d680b345a1826ce40b861cb2f30a19dc878426eb", + "https://esm.sh/gh/mcandeia/punchmole@e295d48d6451934612ab6747b63751fa0dd7d6fe/PunchmoleServer.js": "f119a46c9b09ed67eb32ddd4d31d16054d5d8a95aa7d258452ee2ff158554e7c", + "https://esm.sh/v135/accepts@1.3.8/denonext/accepts.mjs": "322a9733d52bf25517686a83af0de3a4597e067d118dd669fc0b96c43d7df1e4", + "https://esm.sh/v135/body-parser@1.20.2/denonext/body-parser.mjs": "43bd09f900b4c393883c94faa5ee1d96f83ca78f174b08c372bf3d2936cf24ec", + "https://esm.sh/v135/bufferutil@4.0.8/denonext/bufferutil.mjs": "60a4618cbd1a5cb24935c55590b793d4ecb33862357d32e1d4614a0bbb90947f", + "https://esm.sh/v135/bytes@3.1.2/denonext/bytes.mjs": "727f99cf6d5675ef6f5095c87c810c4117074bad2fcb164eac15abdec967d6c1", + "https://esm.sh/v135/call-bind@1.0.5/denonext/callBound.js": "bc9a8f2928b32f3e6a3f7f55c557e68bf05683b297fe6146eaee7a197dcdeb62", + "https://esm.sh/v135/content-disposition@0.5.4/denonext/content-disposition.mjs": "fb55aece0707ff2476ad324c7ed9edb74cef867510edcc771b55940b7d4e36a0", + "https://esm.sh/v135/content-type@1.0.5/denonext/content-type.mjs": "0a76f5bf728090f75a34f227efe77a9ad37c0eaa10539e012bc5dc059b9657fa", + "https://esm.sh/v135/cookie-signature@1.0.6/denonext/cookie-signature.mjs": "67a2f0d8c2c33165424b5d5c363baeae7b8d03ab88fe146859d8e967c6a86410", + "https://esm.sh/v135/cookie@0.6.0/denonext/cookie.mjs": "688128a8a735ce2a9f09fba0051d6f4f7230fd9816a353f5dc75ac40defc1e79", + "https://esm.sh/v135/debug@2.6.9/denonext/debug.mjs": "7f9108f68aa2405666a7eaaac594d67edf26a431364a01d304d2a4daedcf3e5e", + "https://esm.sh/v135/define-data-property@1.1.1/denonext/define-data-property.mjs": "75d78b08cc438962589822efb5946e3b8246bf9d95bedb043092f9a794876f3d", + "https://esm.sh/v135/depd@2.0.0/denonext/depd.mjs": "fda65d2a966c5228c771a03b7756a5be1579e99ce7395f739985d0833c7207fd", + "https://esm.sh/v135/destroy@1.2.0/denonext/destroy.mjs": "eaa02ce589c79ed2ebd90b3fc11dd28cab7886bfbc24e8b91cc4256f1f15cc5c", + "https://esm.sh/v135/ee-first@1.1.1/denonext/ee-first.mjs": "c655caffe0672068733c5cfdfbec78642a8fbf64b4ce3c9fc32f401efefeaa1a", + "https://esm.sh/v135/encodeurl@1.0.2/denonext/encodeurl.mjs": "dccc811e5af731341108e5af6a0ce67f8c948f35bb2b269bd0ea70e1475511e4", + "https://esm.sh/v135/escape-html@1.0.3/denonext/escape-html.mjs": "836d29b9da47c2ac09ffa144f01a4726a38b4e4a9748e37cd115a6316aed2b00", + "https://esm.sh/v135/etag@1.8.1/denonext/etag.mjs": "0343ae4cd11da8da95460e10a4b8e0a5b24a0941ce1678bdf7ddfb0a36703f46", + "https://esm.sh/v135/express@4.19.2/denonext/express.mjs": "5d701edeb3588e7a8993dbb234e4737cf2e940d163994851fafc2f293e3e0beb", + "https://esm.sh/v135/finalhandler@1.2.0/denonext/finalhandler.mjs": "dafe353c8f403f85067d7d0c854a8fff9ebebb1eda434fda32d7ca4a6cdedfd1", + "https://esm.sh/v135/forwarded@0.2.0/denonext/forwarded.mjs": "72e79cf9e130e81a182f26af69493c8f20857b6f03623f16b3afe9e18b25411d", + "https://esm.sh/v135/fresh@0.5.2/denonext/fresh.mjs": "e43787c17dea3cbe8bfed87a051ebb57eaf40d42d34931db2638d437d1766429", + "https://esm.sh/v135/function-bind@1.1.2/denonext/function-bind.mjs": "ba026f8203d5631c23f48de9829c300fea74e2c9b333d06e4f220ae774557ad1", + "https://esm.sh/v135/get-intrinsic@1.2.2/denonext/get-intrinsic.mjs": "f9ed58df1bb1201efc1ab14f7da5ebe5a000b220c6de9430a594d088909742d2", + "https://esm.sh/v135/gh/mcandeia/punchmole@c2246eaeae94451b913dc23113c28e79a4936406/denonext/PunchmoleServer.js": "71cddbfcca19c1b982b7887d02e43bf0a95cd6de314723a224ffa34ca98d0586", + "https://esm.sh/v135/gh/mcandeia/punchmole@e295d48d6451934612ab6747b63751fa0dd7d6fe/denonext/PunchmoleServer.js": "8cee8745d650b24a47a6f31acef8dc74e052ccefaf144fc2ad3a43f9d2811f04", + "https://esm.sh/v135/gopd@1.0.1/denonext/gopd.mjs": "9f8a99c477f272b7e75f0c1f55e43ebb57b121245f403769eb853589a8e31b49", + "https://esm.sh/v135/has-property-descriptors@1.0.1/denonext/has-property-descriptors.mjs": "337d42b1af52a1b68ced55841674d47c33e26da3843119e98a8ae5971aaf194f", + "https://esm.sh/v135/has-proto@1.0.1/denonext/has-proto.mjs": "443f9773e464b39534dbecaee040e5de8384e8efaeea7a6d3e333df4069d1f85", + "https://esm.sh/v135/has-symbols@1.0.3/denonext/has-symbols.mjs": "452727b5dbec94d538c5e8e062ed82bee701c3b752eb21e6189978e92292b7c6", + "https://esm.sh/v135/hasown@2.0.0/denonext/hasown.mjs": "ba954305c23d6c4059f02f6b244bdbf45349e1228e3570ea3dc096d418b9f2f8", + "https://esm.sh/v135/http-errors@2.0.0/denonext/http-errors.mjs": "a0b451acc9c815d5e55a94a1c9bf68b83db9d66be78313d76c6beee40623ca8e", + "https://esm.sh/v135/iconv-lite@0.4.24/denonext/iconv-lite.mjs": "58ae7118ac00d9cae4b8ac0841281999a4f4af0fac54b60cc286fb8f09cdd9ef", + "https://esm.sh/v135/inherits@2.0.4/denonext/inherits.mjs": "8095f3d6aea060c904fb24ae50f2882779c0acbe5d56814514c8b5153f3b4b3b", + "https://esm.sh/v135/ipaddr.js@1.9.1/denonext/ipaddr.mjs": "b53fc977cda5b9ee595ccf9693a02bd6cc9741c51e5d0002b7677bcbd7f300b2", + "https://esm.sh/v135/media-typer@0.3.0/denonext/media-typer.mjs": "40ea9972d4a533872bd3403f95a32f1faddd04817be75597287a7cad60b0a96a", + "https://esm.sh/v135/merge-descriptors@1.0.1/denonext/merge-descriptors.mjs": "de42c926d830041381e6e36fdb576a272047853a01e8d158a59197586a8d11b0", + "https://esm.sh/v135/methods@1.1.2/denonext/methods.mjs": "df1fa8e775f52f9f2ee5edc8ae6a1f587196e535579dd55dd0ac222a0ecbf039", + "https://esm.sh/v135/mime-db@1.52.0/denonext/mime-db.mjs": "52b5de94b0afa2139224f83773d90861fbc2fca6a1994f1a00a52733e14f4720", + "https://esm.sh/v135/mime-types@2.1.35/denonext/mime-types.mjs": "17e735bb133c14d5f6e1e5fe26e92d9e4e10c738f7154ed12ff34c9a74a492cd", + "https://esm.sh/v135/mime@1.6.0/denonext/mime.mjs": "bc4254368bab3d9b5b6c49cf3c5812410cd4ef0d2d1e4356fe9b4f2d65a0c0b9", + "https://esm.sh/v135/ms@2.0.0/denonext/ms.mjs": "3d3c2d9fc775a062db98f0d6da4e660539a5a2f3fc30a017f8381c48e06eb547", + "https://esm.sh/v135/ms@2.1.3/denonext/ms.mjs": "0f06597e493998793b8f52232868976128fca326eec3bcd56f10e66e6efd1839", + "https://esm.sh/v135/negotiator@0.6.3/denonext/negotiator.mjs": "00b4932ba3f3502ac746fd05716058f72d272629bc9c49e286ac483ebb928077", + "https://esm.sh/v135/node-gyp-build@4.6.1/denonext/node-gyp-build.mjs": "5d28b312f145a6cb2ec0dbdd80a7d34c0e0e6b5dcada65411d8bcff6c8991cc6", + "https://esm.sh/v135/object-inspect@1.13.1/denonext/object-inspect.mjs": "5792b0c8afecacc836f58bae74de5ae3cc14c42db9ad40689308a051fb2b432b", + "https://esm.sh/v135/on-finished@2.4.1/denonext/on-finished.mjs": "70097c8fecc444842cc0442a4d6311bd785367e1c71d81d3854151a0baedda52", + "https://esm.sh/v135/parseurl@1.3.3/denonext/parseurl.mjs": "2ce692590dbef75165d259963e2ad99007ef617b74b865cb8072ceefe53290ac", + "https://esm.sh/v135/path-to-regexp@0.1.7/denonext/path-to-regexp.mjs": "1882fa76d8b6aa20a9bcc1a926cbf1a1b6476cb41bec4ee54d6bf33ae627dbee", + "https://esm.sh/v135/proxy-addr@2.0.7/denonext/proxy-addr.mjs": "485dde079d137ca798c63a545eeb1a4bcf1b621228bdfc8cb480119385ccead3", + "https://esm.sh/v135/qs@6.11.0/denonext/qs.mjs": "7c4e41ccbcbc4329e2158cc4c9867e159e1f0dc605475c610de52c28dd34c3eb", + "https://esm.sh/v135/range-parser@1.2.1/denonext/range-parser.mjs": "6c949446640944b4f877b1023adbedbe9c981b960cee369f9a3f7c3dcc3fbee1", + "https://esm.sh/v135/raw-body@2.5.2/denonext/raw-body.mjs": "4ba707518301f8997afc8b6eea2c1fdc052800eb52a6915c3b51683d214ab2f3", + "https://esm.sh/v135/safe-buffer@5.2.1/denonext/safe-buffer.mjs": "facbcb4f4622e13062978522fa12c42cae4e12f55b0e1d3fa1c4bc751bd827c7", + "https://esm.sh/v135/safer-buffer@2.1.2/denonext/safer-buffer.mjs": "ce0e787812c668ba082ad5b75958490c714b6e05836bd5b6013d9f75727c006f", + "https://esm.sh/v135/send@0.18.0/denonext/send.mjs": "55a4fc5d3c46a4b1cf13772f106d5cc9d5d55b7765fbe8bb01731092ef425aba", + "https://esm.sh/v135/serve-static@1.15.0/denonext/serve-static.mjs": "1fdc78cb5f4079ed71cf15480561617c6692270316d3e4b0518d174a12960a0a", + "https://esm.sh/v135/set-function-length@1.1.1/denonext/set-function-length.mjs": "f4f9b843c715b07a2f4bd81c31bd7bc766a2de6391fe4467cfa46212ca61e695", + "https://esm.sh/v135/setprototypeof@1.2.0/denonext/setprototypeof.mjs": "3c433ff9b1f70f65d63ad4320ed48ed97a61393cfe63f9582154c5ef4c1e4ae3", + "https://esm.sh/v135/side-channel@1.0.4/denonext/side-channel.mjs": "ef542973ad0f7a84fd76f5d5613e08693ad52ba930a2cf422da75a70afcd2f81", + "https://esm.sh/v135/statuses@2.0.1/denonext/statuses.mjs": "04eebf4fa5711688b325f7c7b29b31c012cf797b15795832c9b9db65a3f16a5d", + "https://esm.sh/v135/toidentifier@1.0.1/denonext/toidentifier.mjs": "096561de448c16b623c023497eea7ce6232a0c6684bd500761cd35eedd5ed73d", + "https://esm.sh/v135/type-is@1.6.18/denonext/type-is.mjs": "6aa1802245c636bd092715894f56e5c09d8e52b4ac1aa7ccf76c91041c2772ec", + "https://esm.sh/v135/unpipe@1.0.0/denonext/unpipe.mjs": "46c65a06d46ec6b3e9f3fda014053928fe62397a3d894cbb56b13bc25a385bc0", + "https://esm.sh/v135/utf-8-validate@6.0.3/denonext/utf-8-validate.mjs": "410c48d66840e987e474a4849cd25829817415cedd25466280effb1287d05aa5", + "https://esm.sh/v135/utils-merge@1.0.1/denonext/utils-merge.mjs": "0a8a3925b476b5a94da272bb8fbb63728b725185a80b28492825dfb3ca3a3542", + "https://esm.sh/v135/vary@1.1.2/denonext/vary.mjs": "119fef5a9e3d5a74c698ded1a4edd8acfdf75db69c6daabf631bd773964ac860", + "https://esm.sh/v135/ws@8.17.0/denonext/ws.mjs": "098384c6d6b787c4074dae8141a61bfa93a271ccb699b4b426c75141c9ef5560" + }, + "workspace": { + "dependencies": [ + "npm:daisyui@4.4.19" + ] + } +} diff --git a/handlers.client.ts b/handlers.client.ts new file mode 100644 index 0000000..b48dc81 --- /dev/null +++ b/handlers.client.ts @@ -0,0 +1,195 @@ +import { type Channel, makeChan, makeChanStream, makeReadableStream, makeWebSocket } from "./channel.ts"; +import type { ClientMessage, ClientState, ErrorMessage, RegisteredMessage, RequestDataEndMessage, RequestDataMessage, RequestStartMessage, ServerMessage, ServerMessageHandler, WSMessage } from "./messages.ts"; +import { ensureChunked } from "./server.ts"; + +/** + * Handler for the 'registered' server message. + * @param {ClientState} state - The client state. + */ +const registered: ServerMessageHandler = (state) => { + state.live = true; +} + +/** + * Handler for the 'error' server message. + * @param {ClientState} state - The client state. + */ +const error: ServerMessageHandler = (state) => { + state.live = false; +} + +/** + * Handler for the 'request-start' server message. + * @param {ClientState} state - The client state. + * @param {RequestStartMessage} message - The message data. + */ +const onRequestStart: ServerMessageHandler = async (state, message) => { + if (message.headers["upgrade"] === "websocket") { + await handleWebSocket(message, state); + return; + } + if (!message.hasBody) { + doFetch(message, state, state.ch.out); + } else { + const bodyData = makeChan(); + state.requestBody[message.id] = bodyData; + doFetch({ ...message, body: makeReadableStream(bodyData) }, state, state.ch.out).finally(() => { + delete state.requestBody[message.id]; + }); + } +} + +/** + * Handler for the 'request-data' server message. + * @param {ClientState} state - The client state. + * @param {RequestDataMessage} message - The message data. + */ +const onRequestData: ServerMessageHandler = async (state, message) => { + const reqBody = state.requestBody[message.id]; + if (!reqBody) { + console.info("[req-data] req not found", message.id); + return; + } + await reqBody.send?.(ensureChunked(message.chunk)); +} + +/** + * Handler for the 'request-data-end' server message. + * @param {ClientState} state - The client state. + * @param {RequestDataEndMessage} message - The message data. + */ +const onRequestDataEnd: ServerMessageHandler = (state, message) => { + const reqBody = state.requestBody[message.id]; + if (!reqBody) { + return; + } + reqBody.close(); +} + +/** + * Handler for the 'ws-message' server message. + * @param {ClientState} state - The client state. + * @param {WSMessage} message - The message data. + */ +const onWsMessage: ServerMessageHandler = async (state, message) => { + await state.wsMessages?.[message.id]?.send?.(message.data); +} + +/** + * Handler for the 'ws-closed' server message. + * @param {ClientState} state - The client state. + * @param {RegisteredMessage} message - The message data. + */ +const onWsClosed: ServerMessageHandler = (state, message) => { + const messageChan = state.wsMessages[message.id]; + delete state.wsMessages[message.id]; + messageChan?.close(); +} + +/** + * Handlers for various server message types. + * @type {Record>} + */ +// deno-lint-ignore no-explicit-any +const handlersByType: Record> = { + registered, + error, + "request-start": onRequestStart, + "request-data": onRequestData, + "request-end": onRequestDataEnd, + "ws-closed": onWsClosed, + "ws-message": onWsMessage, +} + +/** + * Handles WebSocket connections. + * @param {RequestStartMessage} message - The WebSocket request message. + * @param {ClientState} state - The client state. + */ +async function handleWebSocket(message: RequestStartMessage, state: ClientState) { + const ws = new WebSocket(new URL(message.url, state.localAddr)); + try { + const wsCh = await makeWebSocket(ws, false); + await state.ch.out.send({ + type: "ws-opened", + id: message.id, + }); + state.wsMessages[message.id] = wsCh.out; + (async () => { + try { + for await (const data of wsCh.in.recv()) { + await state.ch.out.send({ + type: "ws-message", + data, + id: message.id, + }); + } + await state.ch.out.send({ + type: "ws-closed", + id: message.id, + }); + } catch (_err) { + // ignore + } finally { + await state.ch.out.send({ + type: "ws-closed", + id: message.id, + }).catch(_err => { }); + delete state.wsMessages[message.id]; + } + })(); + } catch (err) { + await state.ch.out.send({ + type: "data-end", + error: err, + id: message.id + }).catch(console.error); + } +} + +/** + * Fetches data from a request. + * @param {RequestStartMessage & { body?: ReadableStream; }} request - The request data. + * @param {ClientState} state - The client state. + * @param {Channel} clientCh - The client channel. + */ +async function doFetch(request: RequestStartMessage & { body?: ReadableStream; }, state: ClientState, clientCh: Channel) { + // Read from the stream + const response = await fetch(new URL(request.url, state.localAddr), { + ...state.client ? { client: state.client } : {}, + method: request.method, + headers: request.headers, + body: request.body, + }); + await clientCh.send({ + type: "response-start", + id: request.id, + statusCode: response.status, + statusMessage: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + }) + const body = response?.body; + const stream = body ? makeChanStream(body) : undefined; + for await (const chunk of stream?.recv() ?? []) { + await clientCh.send({ + type: "data", + id: request.id, + chunk, + }); + } + await clientCh.send({ + type: "data-end", + id: request.id, + }); + + return response; +} + +/** + * Handles server messages. + * @param {ClientState} state - The client state. + * @param {ServerMessage} message - The server message. + */ +export const handleServerMessage: ServerMessageHandler = async (state, message) => { + await handlersByType?.[message.type]?.(state, message); +} diff --git a/handlers.server.ts b/handlers.server.ts new file mode 100644 index 0000000..8c430df --- /dev/null +++ b/handlers.server.ts @@ -0,0 +1,188 @@ +import { makeReadableStream, makeWebSocket } from "./channel.ts"; +import type { ClientMessage, ClientMessageHandler, DataEndMessage, DataMessage, RegisterMessage, ResponseStartMessage, WSConnectionClosed, WSMessage } from "./messages.ts"; +import { ensureChunked } from "./server.ts"; + +/** + * List of status codes that represent null bodies in responses. + * @type {number[]} + */ +const NULL_BODIES = [101, 204, 205, 304]; + +/** + * Handler for the 'response-start' client message. + * @param {ClientState} state - The client state. + * @param {ResponseStartMessage} message - The message data. + */ +const onResponseStart: ClientMessageHandler = (state, message) => { + const request = state.ongoingRequests[message.id]; + if (!request) { + console.error( + new Date(), + "Didn't find response object, probably dead?", + ); + return; + } + const headers = new Headers(); + Object.entries(message.headers).forEach(([key, value]: [string, string]) => { + headers.set(key, value); + }); + const shouldBeNullBody = NULL_BODIES.includes(message.statusCode); + const stream = !shouldBeNullBody && request.dataChan ? makeReadableStream(request.dataChan) : undefined; + const resp = new Response(stream, { + status: message.statusCode, + statusText: message.statusMessage, + headers, + }); + + request.requestObject?.signal?.addEventListener?.("abort", () => { + if (message.id in state.ongoingRequests) { + delete state.ongoingRequests[message.id]; + request.responseObject.reject(new DOMException("Connection closed", "AbortError")); + } + }); + request.responseObject.resolve(resp); +} + +/** + * Handler for the 'data' client message. + * @param {ClientState} state - The client state. + * @param {DataMessage} message - The message data. + */ +const data: ClientMessageHandler = async (state, message) => { + const request = state.ongoingRequests[message.id]; + if (!request) { + console.error( + new Date(), + "Didn't find response object, unable to send data", + message.id, + ); + return; + } + try { + await request.dataChan?.send(ensureChunked(message.chunk)); + } catch (_err) { + console.log("Request was aborted", _err); + } +} + +/** + * Handler for the 'data-end' client message. + * @param {ClientState} state - The client state. + * @param {DataEndMessage} message - The message data. + */ +const onDataEnd: ClientMessageHandler = (state, message) => { + const request = state.ongoingRequests[message.id]; + if (!request) { + console.error( + new Date(), + "Didn't find response object, unable to send data", + ); + return; + } + if (message.error) { + request.responseObject.reject(new DOMException("Connection closed", JSON.stringify(message.error))); + return; + } + try { + // Call ready again to ensure that all chunks are written + // before closing the writer. + request.dataChan?.close?.(); + } catch (_err) { + console.log(_err); + } +} + +/** + * Handler for the 'ws-closed' client message. + * @param {ClientState} state - The client state. + * @param {WSConnectionClosed} message - The message data. + */ +const onWsClosed: ClientMessageHandler = (state, message) => { + delete state.ongoingRequests[message.id]; +} + +/** + * Handler for the 'ws-message' client message. + * @param {ClientState} state - The client state. + * @param {WSMessage} message - The message data. + */ +const onWsMessage: ClientMessageHandler = async (state, message) => { + await state.ongoingRequests?.[message.id]?.socketChan?.send(message.data) +} + +/** + * Handler for the 'ws-opened' client message. + * @param {ClientState} state - The client state. + * @param {DataEndMessage} message - The message data. + */ +const onWsOpened: ClientMessageHandler = async (state, message) => { + const request = state.ongoingRequests[message.id]; + if (!request) { + return; + } + try { + const { socket, response } = Deno.upgradeWebSocket(request.requestObject); + request.responseObject.resolve(response); + const socketChan = await makeWebSocket(socket, false); + request.socketChan = socketChan.out; + (async () => { + for await (const msg of socketChan.in.recv()) { + await state.ch.out.send({ type: "ws-message", id: message.id, data: msg }); + } + await state.ch.out.send({ type: "ws-closed", id: message.id }) + socket.close(); + })() + } + catch (err) { + console.error(new Date(), "Error upgrading websocket", err); + delete state.ongoingRequests[message.id]; + } +} +/** + * Handler for the 'register' client message. + * @param {ClientState} state - The client state. + * @param {RegisterMessage} message - The message data. + */ +const register: ClientMessageHandler = async (state, message) => { + if (state.apiKeys.includes(message.apiKey)) { + state.domainsToConnections[message.domain] = state.ch; + await state.ch.out.send({ type: "registered", domain: message.domain, id: message.id }) + } else { + console.error( + new Date(), + "Given API key is wrong/not recognised, stopping connection", + message, + ); + await state.ch.out.send({ type: "error", message: "Invalid API key" }) + state.socket.close(); + } +} + +/** + * A record mapping client message types to their respective handlers. + * @type {Record>} + * @ignore + */ +// deno-lint-ignore no-explicit-any +const handlersByType: Record> = { + "response-start": onResponseStart, + data, + "data-end": onDataEnd, + "ws-closed": onWsClosed, + "ws-message": onWsMessage, + "ws-opened": onWsOpened, + register, +} + +/** + * Handles client messages received from the server. + * @param {ClientState} state - The client state. + * @param {ClientMessage} message - The message received from the server. + */ +export const handleClientMessage: ClientMessageHandler = async (state, message) => { + console.info(new Date(), `[server]`, message.type, "id" in message ? message.id : ""); + await handlersByType?.[message.type]?.(state, message)?.catch?.(err => { + console.error("unexpected error happening when handling message", message, err); + delete state.ongoingRequests[message.id]; + }); +} diff --git a/messages.ts b/messages.ts new file mode 100644 index 0000000..16f1e27 --- /dev/null +++ b/messages.ts @@ -0,0 +1,95 @@ +import type { Channel, DuplexChannel } from "./channel.ts"; + +export interface RequestObject { + id: string; + requestObject: Request; + responseObject: ReturnType>; + dataChan?: Channel; + socketChan?: Channel +} + +export interface RegisterMessage { + id: string; + type: "register"; + apiKey: string; + domain: string; +} +export interface ResponseStartMessage { + type: "response-start"; + id: string; + statusCode: number; + statusMessage: string; + headers: Record; +} +export interface DataMessage { + type: "data"; + id: string; + chunk: Uint8Array; +} + +export interface DataEndMessage { + type: "data-end"; + id: string; + error?: unknown; +} +export interface WSConnectionOpened { + type: "ws-opened" + id: string; +} +export interface WSMessage { + type: "ws-message" + id: string; + data: ArrayBuffer; +} +export interface WSConnectionClosed { + type: "ws-closed" + id: string; +} +export type ClientMessage = WSMessage | WSConnectionClosed | WSConnectionOpened | RegisterMessage | ResponseStartMessage | DataMessage | DataEndMessage; + +export interface RequestStartMessage { + type: "request-start"; + domain: string; + id: string; + method: string; + url: string; + headers: Record; + hasBody?: boolean; +} +export interface RequestDataEndMessage { + type: "request-end" + id: string; +} +export interface RequestDataMessage { + type: "request-data"; + id: string; + chunk: Uint8Array +} +export interface RegisteredMessage { + type: "registered"; + id: string; + domain: string; +} +export interface ErrorMessage { + type: "error"; + message: string; +} +export type ServerMessage = WSMessage | WSConnectionClosed | RequestStartMessage | RequestDataEndMessage | RequestDataMessage | RegisteredMessage | ErrorMessage; + +export interface ClientState { + ch: DuplexChannel; + requestBody: Record>; + wsMessages: Record>; + live: boolean; + localAddr: string; + client?: Deno.HttpClient +} +export interface ServerState { + socket: WebSocket; + ch: DuplexChannel; + domainsToConnections: Record>; + ongoingRequests: Record; + apiKeys: string[]; +} +export type ServerMessageHandler = (state: ClientState, message: TServerMessage) => Promise | void; +export type ClientMessageHandler = (state: ServerState, message: TClientMessage) => Promise | void; diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..d242393 --- /dev/null +++ b/mod.ts @@ -0,0 +1,5 @@ +export { connect } from "./client.ts"; +export type { ConnectOptions } from "./client.ts"; +export { start } from "./server.ts"; +export type { ServerOptions } from "./server.ts"; + diff --git a/notify.ts b/notify.ts new file mode 100644 index 0000000..a425365 --- /dev/null +++ b/notify.ts @@ -0,0 +1,93 @@ +/** + * Its necessary since JSR requires the use of JSR deps. + * source from: https://github.com/lambdalisue/deno-async/blob/c86ef00a3056b2436b5e90f01bf52c1cbb83b1c8/notify.ts + */ +export interface WaitOptions { + signal?: AbortSignal; +} + +/** + * Async notifier that allows one or more "waiters" to wait for a notification. + * + * ```ts + * import { assertEquals } from "https://deno.land/std@0.211.0/assert/mod.ts"; + * import { promiseState } from "https://deno.land/x/async@$MODULE_VERSION/state.ts"; + * import { Notify } from "https://deno.land/x/async@$MODULE_VERSION/notify.ts"; + * + * const notify = new Notify(); + * const waiter1 = notify.notified(); + * const waiter2 = notify.notified(); + * notify.notify(); + * assertEquals(await promiseState(waiter1), "fulfilled"); + * assertEquals(await promiseState(waiter2), "pending"); + * notify.notify(); + * assertEquals(await promiseState(waiter1), "fulfilled"); + * assertEquals(await promiseState(waiter2), "fulfilled"); + * ``` + */ +export class Notify { + #waiters: { + promise: Promise; + resolve: () => void; + reject: (reason?: unknown) => void; + }[] = []; + + /** + * Returns the number of waiters that are waiting for notification. + */ + get waiters(): number { + return this.#waiters.length; + } + + /** + * Notifies `n` waiters that are waiting for notification. Resolves each of the notified waiters. + * If there are fewer than `n` waiters, all waiters are notified. + */ + notify(n = 1): void { + const head = this.#waiters.slice(0, n); + const tail = this.#waiters.slice(n); + for (const waiter of head) { + waiter.resolve(); + } + this.#waiters = tail; + } + + /** + * Notifies all waiters that are waiting for notification. Resolves each of the notified waiters. + */ + notifyAll(): void { + for (const waiter of this.#waiters) { + waiter.resolve(); + } + this.#waiters = []; + } + + /** + * Asynchronously waits for notification. The caller's execution is suspended until + * the `notify` method is called. The method returns a Promise that resolves when the caller is notified. + * Optionally takes an AbortSignal to abort the waiting if the signal is aborted. + * + * @param options Optional parameters. + * @param options.signal An optional AbortSignal to abort the waiting if the signal is aborted. + * @throws {DOMException} If the signal is aborted. + */ + async notified({ signal }: WaitOptions = {}): Promise { + if (signal?.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + const waiter = Promise.withResolvers(); + const abort = () => { + removeItem(this.#waiters, waiter); + waiter.reject(new DOMException("Aborted", "AbortError")); + }; + signal?.addEventListener("abort", abort, { once: true }); + this.#waiters.push(waiter); + await waiter.promise; + signal?.removeEventListener("abort", abort); + } +} + +function removeItem(array: T[], item: T): void { + const index = array.indexOf(item); + array.splice(index, 1); +} \ No newline at end of file diff --git a/queue.ts b/queue.ts new file mode 100644 index 0000000..3c5623d --- /dev/null +++ b/queue.ts @@ -0,0 +1,72 @@ +/** + * Its necessary since JSR requires the use of JSR deps. + * source from: https://github.com/lambdalisue/deno-async/blob/c86ef00a3056b2436b5e90f01bf52c1cbb83b1c8/queue.ts + */ +import { Notify, type WaitOptions } from "./notify.ts"; + +/** + * A queue implementation that allows for adding and removing elements, with optional waiting when + * popping elements from an empty queue. + * + * ```ts + * import { assertEquals } from "https://deno.land/std@0.211.0/assert/mod.ts"; + * import { Queue } from "https://deno.land/x/async@$MODULE_VERSION/queue.ts"; + * + * const queue = new Queue(); + * queue.push(1); + * queue.push(2); + * queue.push(3); + * assertEquals(await queue.pop(), 1); + * assertEquals(await queue.pop(), 2); + * assertEquals(await queue.pop(), 3); + * ``` + * + * @template T The type of items in the queue. + */ +export class Queue { + #notify = new Notify(); + #items: T[] = []; + + /** + * Gets the number of items in the queue. + */ + get size(): number { + return this.#items.length; + } + + /** + * Returns true if the queue is currently locked. + */ + get locked(): boolean { + return this.#notify.waiters > 0; + } + + /** + * Adds an item to the end of the queue and notifies any waiting consumers. + * + * @param {T} value The item to add to the queue. + */ + push(value: T): void { + this.#items.push(value); + this.#notify.notify(); + } + + /** + * Removes the next item from the queue, optionally waiting if the queue is currently empty. + * + * @param {WaitOptions} [options] Optional parameters to pass to the wait operation. + * @param {AbortSignal} [options.signal] An optional AbortSignal used to abort the wait operation if the signal is aborted. + * @returns {Promise} A promise that resolves to the next item in the queue. + * @throws {DOMException} Throws a DOMException with "Aborted" and "AbortError" codes if the wait operation was aborted. + */ + async pop({ signal }: WaitOptions = {}): Promise { + while (!signal?.aborted) { + const value = this.#items.shift(); + if (value) { + return value; + } + await this.#notify.notified({ signal }); + } + throw new DOMException("Aborted", "AbortError"); + } +} \ No newline at end of file diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..faeda57 --- /dev/null +++ b/server.ts @@ -0,0 +1,116 @@ +import { type DuplexChannel, makeChan, makeChanStream, makeWebSocket } from "./channel.ts"; +import { handleClientMessage } from "./handlers.server.ts"; +import type { ClientMessage, RequestObject, ServerMessage, ServerState } from "./messages.ts"; + +/** + * Ensures that the given chunk is in the form of a Uint8Array. + * If it's not already an array, it converts the provided object into a Uint8Array. + * @param {Uint8Array | Record} chunk - The input chunk, which can be either a Uint8Array or an object. + * @returns {Uint8Array} The chunk converted into a Uint8Array. + */ +export const ensureChunked = (chunk: Uint8Array | Record): Uint8Array => { + if (Array.isArray(chunk)) { + return chunk as Uint8Array; + } + return new Uint8Array(Array.from({ ...chunk, length: Object.keys(chunk).length })) +} + + +const domainsToConnections: Record> = {}; +const ongoingRequests: Record = {}; + +/** + * Represents options for configuring the server. + * @typedef {Object} ServerOptions + * @property {string[]} apiKeys - An array of API keys for authentication. + * @property {number} port - The port number where the server will listen for connections. + */ +export interface ServerOptions { + apiKeys: string[] + port: number; +} + +/** + * Starts the Warp server. + * @param {ServerOptions} [options] - Optional configurations for the server. + * @returns {Deno.HttpServer} An instance of Deno HTTP server. + */ +export const start = (options?: ServerOptions): Deno.HttpServer => { + const port = (options?.port ?? Deno.env.get("PORT")); + const apiKeys = options?.apiKeys ?? Deno.env.get("API_KEYS")?.split(",") ?? []; // array of api keys (random strings) + + return Deno.serve({ + handler: async (req) => { + const url = new URL(req.url); + if (url.pathname === "/_connect") { + const { socket, response } = Deno.upgradeWebSocket(req); + (async () => { + const ch = await makeWebSocket(socket); + const state: ServerState = { + socket, + ch, + domainsToConnections, + ongoingRequests, + apiKeys, + } + for await (const message of ch.in.recv()) { + await handleClientMessage(state, message); + } + })() + return response; + + } + const host = req.headers.get("host"); + if (host && host in domainsToConnections) { + const ch = domainsToConnections[host]; + const messageId = crypto.randomUUID(); + const hasBody = !!req.body; + const url = new URL(req.url); + const requestForward: ServerMessage = { + type: "request-start", + domain: host, + id: messageId, + method: req.method, + hasBody, + url: (url.pathname + url.search), + headers: [...req.headers.entries()].reduce((acc, [key, value]) => { + acc[key] = value; + return acc; + }, {} as Record), + }; + + // Create a writable stream using TransformStream + const responseObject = Promise.withResolvers(); + ongoingRequests[messageId] = { + id: messageId, + requestObject: req, + responseObject, + dataChan: makeChan(), + } + try { + await ch.out.send(requestForward); + const dataChan = req.body ? makeChanStream(req.body) : undefined; + (async () => { + for await (const chunk of dataChan?.recv() ?? []) { + await ch.out.send({ + type: "request-data", + id: messageId, + chunk, + }); + } + await ch.out.send({ + type: "request-end", + id: messageId, + }); + })() + return responseObject.promise; + } catch (err) { + console.error(new Date(), "Error sending request to remote client", err); + return new Response("Error sending request to remote client", { status: 503 }); + } + } + return new Response("No registration for domain and/or remote service not available", { status: 503 }); + }, + port: port ? +port : 8000, + }) +} \ No newline at end of file diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..58f45e5 --- /dev/null +++ b/test.ts @@ -0,0 +1,72 @@ +import { connect } from "./client.ts"; +import { start } from "./server.ts"; + +const LOCAL_PORT = 8000; +const _localServer = Deno.serve({ + handler: (req) => { + if (req.url.endsWith("/connect-ws")) { + const { socket, response } = Deno.upgradeWebSocket(req); + socket.onclose = () => { + console.log("CLOSED"); + } + socket.onopen = () => { + console.log("OPEN"); + socket.send(JSON.stringify({ ping: true })); + } + socket.onmessage = (msg) => { + console.log("MESSAGE RECEIVED", msg); + } + return response; + } + const cp = new Headers(req.headers); + cp.set("x-server-reply", "true"); + return new Response(JSON.stringify({ message: "HELLO WORLD" }), { + headers: { + 'content-type': "application/json", + }, status: 200 + }); + }, + port: LOCAL_PORT, +}); + + +const KEY = "c309424a-2dc4-46fe-bfc7-a7c10df59477"; + +const _tunnelServer = start({ + apiKeys: [KEY], + port: 8001 +}); + +const domain = "localhost:8001"; +await connect({ + domain, + localAddr: "http://localhost:8000", + server: "ws://localhost:8001", + token: KEY, +}); + + +const client = Deno.createHttpClient({ + allowHost: true, +}); + +const resp = await fetch("http://localhost:8001", { + method: "POST", + headers: { + "x-client-request": "true", + "host": domain + }, + client, + body: "Hello World", +}); +console.log("TEXT", await resp.text(), resp.headers); + + +const ws = new WebSocket("ws://localhost:8001/connect-ws"); +ws.onmessage = (msg) => { + console.log("MESSAGE CLIENT RECEIVED", msg); +} +ws.onopen = () => { + ws.send(JSON.stringify({ pong: true })) + ws.close(); +} \ No newline at end of file