This commit is contained in:
sam 2024-04-23 20:37:20 +02:00
commit 8ca9b01243
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
30 changed files with 1885 additions and 0 deletions

5
.editorconfig Normal file
View file

@ -0,0 +1,5 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true

4
.gitattributes vendored Normal file
View file

@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Swap the comments on the following lines if you wish to use zero-installs
# In that case, don't forget to run `yarn config set enableGlobalCache false`!
# Documentation here: https://yarnpkg.com/features/caching#zero-installs
#!.yarn/cache
.pnp.*
build/
node_modules

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"useTabs": true,
"singleQuote": false,
"trailingComma": "all"
}

4
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "modificationsIfAvailable"
}

1
.yarnrc.yml Normal file
View file

@ -0,0 +1 @@
nodeLinker: node-modules

1
README.md Normal file
View file

@ -0,0 +1 @@
# foxapp

8
package.json Normal file
View file

@ -0,0 +1,8 @@
{
"name": "foxapp",
"packageManager": "yarn@4.1.0",
"private": true,
"workspaces": [
"packages/*"
]
}

16
packages/cli/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "cli",
"dependencies": {
"foxchat.js": "workspace:^"
},
"devDependencies": {
"tsx": "^3.12.7",
"typescript": "^5.1.6"
},
"scripts": {
"build": "tsc",
"dev": "tsx --watch --conditions=development src/index.ts",
"start": "node build/index.js"
},
"type": "module"
}

11
packages/cli/src/index.ts Normal file
View file

@ -0,0 +1,11 @@
import { Client } from "foxchat.js";
const client = new Client({
instance: "id.fox.localhost",
token: "NbV7gaEZaTnhJFF19tt5IkOsscAWkKhuLk7WdkIPfmM",
debug: true,
});
(async () => {
await client.connect();
})();

View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"moduleResolution": "NodeNext",
"module": "NodeNext",
"outDir": "build",
"strict": true,
"target": "ES2022",
"allowSyntheticDefaultImports": true
}
}

View file

@ -0,0 +1,25 @@
{
"name": "foxchat.js",
"description": "Client library for foxchat",
"exports": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./build/index.js"
},
"private": true,
"scripts": {
"build": "tsc"
},
"type": "module",
"devDependencies": {
"@types/ws": "^8.5.10",
"prettier": "^3.2.5",
"typed-emitter": "^2.1.0",
"typescript": "^5.3.3"
},
"dependencies": {
"eventemitter3": "^5.0.1",
"isomorphic-ws": "^5.0.0",
"ws": "^8.16.0"
}
}

View file

@ -0,0 +1,80 @@
import { RestClient } from "./rest/index.js";
import { WebsocketClient } from "./ws/index.js";
import * as events from "./entities/ws/events.js";
import EventEmitter from "eventemitter3";
import { Dispatch } from "./entities/ws/payload.js";
export interface ClientOptions {
instance: string;
token: string;
debug?: boolean;
}
export type ChatInstance = {
id: string;
domain: string;
};
export type ClientEvents = {
ready: (ready: events.Ready & { instance: ChatInstance }) => void;
messageCreate: (
message: events.MessageCreate & { instance: ChatInstance },
) => void;
// TODO: have this emit an actual type
error: (message: string) => void;
};
export default class Client extends EventEmitter<ClientEvents> {
#options: ClientOptions;
#restClient: RestClient;
#wsClient: WebsocketClient;
constructor(opts: ClientOptions) {
super();
this.#options = opts;
this.#restClient = new RestClient(
this.#options.instance,
this.#options.token,
);
this.#wsClient = new WebsocketClient(
this.#options.instance,
this.#options.token,
{ debug: this.#options.debug },
);
this.#wsClient.on("event", this.#handleEvent);
}
get rest() {
return this.#restClient;
}
get ws() {
return this.#wsClient;
}
async connect() {
await this.#wsClient.connect();
}
#handleEvent(instanceId: string, event: Dispatch) {
const instance = this.#wsClient.instances.find((i) => i.id === instanceId);
if (!instance) {
this.emit(
"error",
`Unknown instance ID for event with type "${event.t}"`,
);
return;
}
switch (event.t) {
case "MESSAGE_CREATE":
this.emit("messageCreate", { ...event.d, instance });
break;
case "READY":
this.emit("ready", { ...event.d, instance });
}
}
}

View file

@ -0,0 +1,3 @@
export default Object.freeze({
serverProxyHeader: "X-Foxchat-Server",
});

View file

@ -0,0 +1,15 @@
export interface Guild {
id: string;
name: string;
owner_ids: string[];
channels?: PartialChannel[];
}
export interface CreateGuildParams {
name: string;
}
export interface PartialChannel {
id: string;
name: string;
}

View file

@ -0,0 +1,3 @@
export { Guild, CreateGuildParams, PartialChannel } from "./guild.js";
export { PartialUser } from "./user.js";
export { Message, CreateMessageParams } from "./message.js";

View file

@ -0,0 +1,13 @@
import { PartialUser } from "./user.js";
export interface Message {
id: string;
channel_id: string;
author: PartialUser;
content: string | null;
created_at: string;
}
export interface CreateMessageParams {
content: string;
}

View file

@ -0,0 +1,6 @@
export interface PartialUser {
id: string;
username: string;
instance: string;
avatar_url?: string;
}

View file

@ -0,0 +1,4 @@
export interface Node {
software: string;
public_key: string;
}

View file

@ -0,0 +1,6 @@
export interface User {
id: string;
username: string;
instance: string;
avatar_url: string | null;
}

View file

@ -0,0 +1,10 @@
import { Guild } from "../rest/chat/guild.js";
import { Message } from "../rest/chat/message.js";
import { User } from "../user.js";
export type Ready = {
user: User;
guilds: Guild[];
};
export type MessageCreate = Message & { guild_id: string };

View file

@ -0,0 +1,24 @@
import type * as events from "./events";
export type Dispatch =
| { t: "MESSAGE_CREATE"; d: events.MessageCreate }
| { t: "READY"; d: events.Ready };
export type ServerPayload =
| { t: "D"; d: { e: Dispatch; s: string } }
| { t: "ERROR"; d: { message: string } }
| {
t: "HELLO";
d: {
heartbeat_interval: number;
guilds: string[];
instances: { id: string; domain: string }[];
};
}
| { t: "HEARTBEAT_ACK"; d: { t: number } };
export type ClientPayload =
| { t: "IDENTIFY"; d: { token: string } }
| { t: "HEARTBEAT"; d: { t: number } };
export type Payload = ServerPayload | ClientPayload;

View file

@ -0,0 +1,3 @@
export { default as Client } from "./Client.js";
export { RestClient, ChatRestClient, RestError } from "./rest/index.js";
export { default as Constants } from "./Constants.js";

View file

@ -0,0 +1,136 @@
import RestError, { ErrorCode } from "./RestError.js";
import type * as ident from "../entities/rest/ident/index.js";
import type * as chat from "../entities/rest/chat/index.js";
import Constants from "../Constants.js";
export default class RestClient {
#instance: string;
#token: string;
constructor(instance: string, token: string) {
this.#instance = instance;
this.#token = token;
}
/** Returns a new ChatRestClient for the given instance. */
chat(instance: string) {
return new ChatRestClient(this, this.#instance, instance);
}
/**
* Makes a request with this client's token, and handles error responses.
* Clients should usually use `requestIdentity` or `requestChat` instead.
* @param method The HTTP method to use.
* @param url The URL to request.
* @param extraHeaders Extra headers to add to the request, if any.
* @param body The request body, if any.
* @returns A parsed JSON response.
* @throws RestError
*/
async rawRequest<T>(
method: string,
url: string,
extraHeaders?: Record<string, string>,
body?: any,
): Promise<T> {
const requestBody = body ? JSON.stringify(body) : undefined;
const headers = requestBody
? {
"Content-Type": "application/json",
"Content-Length": `${requestBody.length}`,
}
: undefined;
const resp = await fetch(url, {
method,
headers: {
...(headers || {}),
...(extraHeaders || {}),
Authorization: this.#token,
},
body: requestBody,
});
// If we didn't get a JSON response, return
if (resp.headers.get("Content-Type") !== "application/json") {
throw new RestError(ErrorCode.RESPONSE_NOT_JSON);
}
const data = await resp.json();
if (resp.status < 200 || resp.status >= 400)
throw new RestError(data.code, data.message);
return data as T;
}
/**
* Makes a request to an identity instance endpoint. The string `/_fox/ident` is automatically prepended to the path.
* For example, when making a request to `/_fox/ident/node`, `path` should be `/node`.
* @param method The HTTP method to use.
* @param path The path to request.
* @param body The request body, if any.
* @returns A parsed JSON response.
* @throws RestError
*/
request<T>(method: string, path: string, body?: any): Promise<T> {
return this.rawRequest(
method,
`https://${this.#instance}/_fox/ident${path}`,
undefined,
body,
);
}
/** Get the current instance's node data. */
getNode() {
return this.request<ident.Node>("GET", "/node");
}
}
export class ChatRestClient {
#chatInstance: string;
#identityInstance: string;
#restClient: RestClient;
constructor(
restClient: RestClient,
identityInstance: string,
chatInstance: string,
) {
this.#restClient = restClient;
this.#identityInstance = identityInstance;
this.#chatInstance = chatInstance;
}
/**
* Makes a request to a proxied chat instance endpoint. The string `/_fox/proxy` is automatically prepended to the path.
* For example, when making a request to `/_fox/proxy/channels/01HMKRAG13AGK1WN7XJ0BY2KGB/messages`,
* `path` should be `/channels/01HMKRAG13AGK1WN7XJ0BY2KGB/messages`.
* @param method The HTTP method to use.
* @param path The path to request.
* @param body The request body, if any.
* @returns A parsed JSON response.
* @throws RestError
*/
request<T>(method: string, path: string, body?: any): Promise<T> {
return this.#restClient.rawRequest(
method,
`https://${this.#identityInstance}/_fox/proxy${path}`,
{ [Constants.serverProxyHeader]: this.#chatInstance },
body,
);
}
createGuild(name: string) {
return this.request<chat.Guild>("POST", "/guilds", { name });
}
createMessage(channelId: string, content: string) {
return this.request<chat.Message>(
"POST",
`/channels/${channelId}/messages`,
{ content },
);
}
}

View file

@ -0,0 +1,26 @@
export default class RestError {
#code: ErrorCode;
#message: string | undefined;
constructor(code: ErrorCode, message: string | undefined = undefined) {
this.#code = code;
this.#message = message;
}
is(code: ErrorCode) {
return this.#code === code;
}
toString() {
return this.#message
? `RestError(${this.#code}): ${this.#message}`
: `RestError(${this.#code})`;
}
}
export enum ErrorCode {
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR",
// Client-specific errors
RESPONSE_NOT_JSON = "ResponseNotJson",
}

View file

@ -0,0 +1,2 @@
export { default as RestClient, ChatRestClient } from "./RestClient.js";
export { default as RestError, ErrorCode } from "./RestError.js";

View file

@ -0,0 +1,144 @@
import WebSocket from "isomorphic-ws";
import EventEmitter from "eventemitter3";
import {
ClientPayload,
Dispatch,
ServerPayload,
} from "../entities/ws/payload.js";
export type ClientOptions = {
debug?: boolean;
};
type WebsocketEvents = {
event: (instanceId: string, event: Dispatch) => void;
error: (message: string) => void;
wsError: (error: WebSocket.ErrorEvent) => void;
wsClose: (event: WebSocket.CloseEvent) => void;
};
export default class Client extends EventEmitter<WebsocketEvents> {
#instance: string;
#token: string;
#ws: WebSocket | undefined;
#debug = false;
#connected = false;
#instances: Array<{ id: string; domain: string }> = [];
#guilds: string[] = [];
#heartbeatTimer: any = undefined;
#heartbeatInterval: number | undefined;
#lastHeartbeatSend: number | undefined;
#lastHeartbeatAck: number | undefined;
constructor(instance: string, token: string, opts?: ClientOptions) {
super();
this.#debug = opts?.debug || false;
this.#instance = instance;
this.#token = token;
}
async connect() {
this.#debug &&
console.log(`Connecting to "wss://${this.#instance}/_fox/ident/ws"`);
this.#ws = new WebSocket(`wss://${this.#instance}/_fox/ident/ws`);
this.#ws.onerror = (error) => {
this.emit("wsError", error);
};
this.#ws.onopen = () => {
this.send({ t: "IDENTIFY", d: { token: this.#token } });
};
this.#ws.onmessage = (event) => {
if (typeof event.data === "string") {
this.handleMessage(JSON.parse(event.data));
}
};
this.#ws.onclose = (event) => {
this.emit("wsClose", event);
};
}
disconnect() {
this.#ws?.close();
}
send(payload: ClientPayload) {
if (this.#debug)
console.log(
"[CLIENT] Sending %s (%s)",
payload.t,
JSON.stringify(payload.d),
);
if (!this.#ws) throw "Socket is closed";
this.#ws.send(JSON.stringify(payload));
}
handleMessage(payload: ServerPayload) {
if (this.#debug)
console.log(
"[SERVER] Received %s (%s)",
payload.t,
JSON.stringify(payload.d),
);
switch (payload.t) {
case "HELLO":
this.#connected = true;
this.#guilds = payload.d.guilds;
this.#instances = payload.d.instances;
// Start heartbeat interval
this.#heartbeatInterval = payload.d.heartbeat_interval;
if (this.#heartbeatTimer) {
clearInterval(this.#heartbeatTimer);
}
this.#heartbeatTimer = setInterval(() => {
this.heartbeat();
}, this.#heartbeatInterval);
break;
case "HEARTBEAT_ACK":
this.#lastHeartbeatAck = Date.now();
break;
case "D":
this.emit("event", payload.d.s, payload.d.e);
break;
case "ERROR":
this.emit("error", payload.d.message);
break;
}
}
heartbeat() {
if (
this.#heartbeatInterval &&
this.#lastHeartbeatAck &&
this.#heartbeatInterval * 3 < Date.now() - this.#lastHeartbeatAck
) {
this.emit(
"error",
`Last heartbeat ACK was more than ${this.#heartbeatInterval * 3}ms ago, disconnecting`,
);
this.disconnect();
}
this.#lastHeartbeatSend = Date.now();
this.send({
t: "HEARTBEAT",
d: { t: Math.round(this.#lastHeartbeatSend / 1000) },
});
}
get instances() {
return this.#instances;
}
get ping() {
if (!this.#lastHeartbeatAck || !this.#lastHeartbeatSend) return 0;
this.#lastHeartbeatAck - this.#lastHeartbeatSend;
}
}

View file

@ -0,0 +1 @@
export { default as WebsocketClient } from "./WebsocketClient.js";

View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"moduleResolution": "node",
"module": "ES6",
"outDir": "build",
"strict": true,
"target": "ES2022",
"allowSyntheticDefaultImports": true
}
}

1293
yarn.lock Normal file

File diff suppressed because it is too large Load diff