Skip to content

Latest commit

 

History

History
470 lines (350 loc) · 13.7 KB

V2.md

File metadata and controls

470 lines (350 loc) · 13.7 KB

Bare client documentation

Importing the library

The API for the client was designed with all use-cases in mind. You should be able to import the library into your code without changing how it's bundled/the module type.

In order to accomplish this, we provide two versions of the client:

  • ESM - ECMAScript modules, import syntax
  • CommonJS - CommonJS modules, require() syntax

We encourage the use of modern JavaScript and recommend using ESM if you're starting a new project/codebase. But you don't have to.

Browsers/Deno (ESM)

Deno is a potential environment for the Bare client. It supports fetch() and importing web URLs

But usually, this is done in browsers

<script type="module">
	// type="module" indicates that this script uses ESM
	// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#applying_the_module_to_your_html
	import { createBareClient } from 'https://unpkg.com/@tomphttp/[email protected]/dist/index.js';
</script>

Browsers (CommonJS)

Bare client has an alternative API that gives you access to all the exports in the bare variable.

<script src="https://unpkg.com/@tomphttp/[email protected]/dist/bare.cjs"></script>
<script>
	bare.BareClient; // class...
	bare.createBareClient; // function...
</script>

Module

This can be done in NodeJS v17+ (fetch() api was added natively) or via a bundler.

Bundlers include: webpack, rollup

Install with NPM:

npm i @tomphttp/bare-client
import { BareClient, createBareClient } from '@tomphttp/bare-client';

Module (CommonJS)

const { BareClient, createBareClient } = require('@tomphttp/bare-client');

Bare manifests

This concept is fundamental to understanding what API to use for creating an instance of BareClient.

Bare servers have a manifest that expose JSON data that help the client determine what versions of the Bare server are supported.

Here's an example:

https://uv.holyubofficial.net/

{
	"versions": ["v1", "v2", "v3"],
	"language": "NodeJS",
	"memoryUsage": 22.84,
	"project": {
		"name": "bare-server-node",
		"description": "TOMPHTTP NodeJS Bare Server",
		"repository": "https://github.com/tomphttp/bare-server-node",
		"version": "2.0.0-beta"
	}
}

This will be fetched one way or another by the client. The link to the manifest is used as a base URL for the client.

It's expected that APIs such as ./v2/ relative to the manifest URL.

Creating a BareClient instance

Now that you have access to BareClient and createBareClient, you have several ways to utilize the library.

Synchronously

One of the APIs for new BareClient():

class BareClient {
	// ...
	/**
	 * Lazily create a BareClient. Calls to fetch and connect will request the manifest once on-demand.
	 * @param server A full URL to the bare server.
	 * @param signal An abort signal for fetching the manifest on demand.
	 */
	// ...
}

Usage:

const client = new BareClient("https://uv.holyubofficial.net/"); // string
const client = new BareClient(new URL("https://uv.holyubofficial.net/")); // accepts instances of `URL`

If you have your Bare server on http://example.com/bare/, and the page is on example.com, you might be tempted to do:

const client = new BareClient('/bare/');

THIS WILL NOT WORK!

Uncaught TypeError: URL constructor: /bare/ is not a valid URL.

This is because you need to pass a full URL. Here's a few examples:

// Similar to passing `/bare/`, except it's being resolved using the `location` and `URL` api:
const client = new BareClient(new URL("/bare/", location.toString()));

// A more compact solution that's slightly less recommended:
// location.origin will always be http://example.com
// There's no `/` at the end of the origin so this will work.
const client = new BareClient(`${location.origin}/bare/`);

on-demand mode

If you don't pass a manifest to new BareClient, BareClient will enter on-demand mode.

on-demand mode is as such:

The client won't know what version of the Bare server is supported until the manifest is fetched. The manifest won't be fetched until client.fetch is called. Because client.fetch is asynchronous, the API doesn't change when on-demand mode is enabled. If the manifest fails to be fetch, an error will be thrown in client.fetch. If an error was thrown, the client will try to fetch the manifest the next time client.fetch is called. client.createWebSocket won't work unless the manifest has been fetched.

client.createWebSocket not working unless client.fetch was called should be a huge deal depending on your use-case. If you don't expect to be creating a WebSocket from that environment (you're probably in a Service Worker), on-demand mode is perfect. If it's still a big deal, see Synchronously with cache and Asynchronously

Synchronously with cache

Other API for new BareClient():

class BareClient {
	// ...
	/**
	 * Immediately create a BareClient.
	 * @param server A full URL to the bare server.
	 * @param manfiest A Bare server manifest.
	 */
	constructor(server: string | URL, manfiest?: BareManifest);
	// ...
}

You can re-use the Bare server manifest from one instance to another.

For example, this code won't work due to the Bare client being in on-demand mode:

const client = new BareClient('https://uv.holyubofficial.net');

// Uncaught TypeError: You need to wait for the client to finish fetching the manifest before creating any WebSockets. Try caching the manifest data before making this request.
while (true) client.createWebSocket('wss://www.google.com/');

However, this code will:

// takes some time to fetch the manifest
const firstClient = await createBareServer('https://uv.holyubofficial.net');

// instant
const secondClient = new BareClient(
	'https://uv.holyubofficial.net',
	firstClient.manifest
);

// you're free to create all the WebSockets you want...
while (true) secondClient.createWebSocket('wss://www.google.com/');

The manifest is serializable and can be passed from a Service Worker to a client (this is a use-case for TompHTTP Service Worker proxies).

const secondClient = new BareClient(
	'https://uv.holyubofficial.net',
	JSON.parse(JSON.stringify(firstClient.manifest))
);

Asynchronously

This will fetch the manifest before making the BareClient available to you. The returned client is guaranteed to work with .createWebSocket.

// takes some time to fetch the manifest
const client = await createBareServer('https://uv.holyubofficial.net');

// but it's worth it
// now you're free to create all the WebSockets you want...
while (true) client.createWebSocket('wss://www.google.com/');

Making requests

The client exposes a fetch-like API:

class BareClient {
	// ...
	async fetch(
		url: string | URL,
		init?: RequestInit
	): Promise<BareResponseFetch>;
	// ...
}

It does NOT accept instances of Request due to too many weird technicalities (restricted headers/headers being lost, duplex not being accessible, body always being a ReadableStream)

Example: OpenAI API

API used in this example: https://platform.openai.com/docs/api-reference/completions/create

// const client = ...

async function openaiCompletions(OPENAI_API_KEY, prompt) {
	const res = await client.fetch('https://api.openai.com/v1/completions', {
		method: 'POST',
		headers: {
			'content-type': 'application/json',
			authorization: `Bearer ${OPENAI_API_KEY}`,
		},
		body: JSON.stringify({
			model: 'text-davinci-003',
			prompt,
			max_tokens: 7,
			temperature: 0,
		}),
	});

	return res.json();
}

const res = await openaiCompletions(
	'my-api-key-asjkdioasopd',
	'Say this is a test'
);
console.log(res); // {id: ...,choices: [{...}]}

Example: GitHub's API

const res = await client.fetch('https://api.github.com/orgs/tomphttp', {
	headers: new Headers({
		'user-agent': navigator.userAgent,
	}),
});
// We pass (url: Request, init: RequestInit)

console.log(res.status); // 200!
console.log(await res.json()); // {login: 'tomphttp', id: 98234273, ... }

Connecting to WebSockets

The client exposes a WebSocket-like API:

class BareClient {
	// ...
	createWebSocket(
		remote: string | URL,
		protocols?: BareWebSocket.Options | string | string[] | undefined,
		options?: BareWebSocket.Options
	): WebSocket;
	// ...
}

Options:

interface Options {
	/**
	 * A provider of request headers to pass to the remote.
	 * Usually one of `User-Agent`, `Origin`, and `Cookie`
	 * Can be just the headers object or an synchronous/asynchronous function that returns the headers object
	 */
	headers?: BareWebSocket.HeadersProvider;
	/**
	 * A hook executed by this function with helper arguments for hooking the readyState property. If a hook isn't provided, bare-client will hook the property on the instance. Hooking it on an instance basis is good for small projects, but ideally the class should be hooked by the user of bare-client.
	 */
	readyStateHook?:
		| ((
				socket: WebSocket,
				getReadyState: BareWebSocket.GetReadyStateCallback
		  ) => void)
		| undefined;
	/**
	 * A hook executed by this function with helper arguments for determining if the send function should throw an error. If a hook isn't provided, bare-client will hook the function on the instance.
	 */
	sendErrorHook?:
		| ((
				socket: WebSocket,
				getSendError: BareWebSocket.GetSendErrorCallback
		  ) => void)
		| undefined;
	/**
	 * A hook executed by this function with the URL. If a hook isn't provided, bare-client will hook the URL.
	 */
	urlHook?: ((socket: WebSocket, url: URL) => void) | undefined;
	/**
	 * A hook executed by this function with a helper for getting the current fake protocol. If a hook isn't provided, bare-client will hook the protocol.
	 */
	protocolHook?:
		| ((
				socket: WebSocket,
				getProtocol: BareWebSocket.GetProtocolCallback
		  ) => void)
		| undefined;
	/**
	 * A callback executed by this function with an array of cookies. This is called once the metadata from the server is received.
	 */
	setCookiesCallback?: ((setCookies: string[]) => void) | undefined;
	webSocketImpl?: WebSocketImpl;
}

Example:

Postman hosts a WebSocket echo API. Anything you send to the socket will be sent directly back to you. This is an easy way to test WebSockets.

See https://blog.postman.com/introducing-postman-websocket-echo-service/

const socket = client.createWebSocket('wss://ws.postman-echo.com/raw');
console.log('URL:', socket.url); // wss://ws.postman-echo.com/raw

socket.addEventListener('open', () => {
	console.log('Open!');
	console.log('Saying hi to Postman');
	socket.send('Hello, Postman!');
});

socket.addEventListener('message', (event) => {
	console.log('Postman says:', event.data); // Postman says: Hello, Postman!
});

Passing a protocol:

client.createWebSocket('wss://ws.postman-echo.com/raw', 'test-protocol');

Passing headers:

Usually this is done to pass Origin, User-Agent, and Cookie

undefined is passed for the protocol to specify no protocol. we just want to specify options

client.createWebSocket('wss://ws.postman-echo.com/raw', undefined, {
	headers: {
		'user-agent': navigator.userAgent, // pass the browser user-agent
	},
});

Regarding hooks (and a lesson in hooks)

Bare client exposes every hook possible for WebSockets. By default, the Bare client will hook the WebSocket itself which should work with most websites, however they can easily be bypassed if the WebSocket.prototype methods are called on the instance of WebSocket. Bare client will never modify the prototypes of objects, it's up to the implementation to do that.

Example: getSendError

The purpose of this hook is to check if the WebSocket should throw an error according to its fake readyState. readyState on the WebSocket is spoofed due to the open event being deferred until the Bare server connects to the destination server.

This is the default hook done by Bare client. No prototype hooks here:

socket.send = function (...args) {
	const error = getSendError();

	if (error) throw error;
	else WebSocketFields.prototype.send.call(this, ...args);
};

This is what a more proper implementation would look like:

/**
 * A map containing the send hooks of hooked WebSockets
 * @type {WeakMap<WebSocket, import('@tomphttp/bare-client').BareWebSocket.GetSendErrorCallback}
 */
const getSendErrorMap = new WeakMap();

// save the unhooked function for later
const { send } = WebSocket.prototype;

WebSocket.prototype.send = function (...args) {
	const getSendError = getSendErrorMap.get(this);

	if (getSendError) {
		const error = getSendError();
		if (error) throw error;
	}

	return Reflect.apply(target, that, args);
};

client.createWebSocket('wss://ws.postman-echo.com/raw', undefined, {
	headers: {
		'user-agent': navigator.userAgent, // pass the browser user-agent
		sendErrorHook: (socket, getSendError) => {
			getSendErrorMap.set(socket, getSendError);
		},
	},
});

This is a basic implementation and it has some pitfalls like:

  • The toString() value is leaked: Unhooked:

    WebSocket.prototype.send.toString(); // function send() { [native code] }

    Hooked:

    WebSocket.prototype.send.toString(); // function (...args) { /* ... */ }

    This can be fixed by hooking Function.prototype.toString.

  • send contains a prototype Unhooked:

    'prototype' in WebSocket.prototype.send; // false

    Hooked:

    WebSocket.prototype.send.toString(); // true

    This can be fixed by using shorthand methods.

Whether your hooks get detected is more of a proxy script issue than a Bare client issue. You've been given every opportunity to hide the hook.