feat: dynamic resolver

This commit is contained in:
Pooya Parsa
2024-02-24 17:25:46 +01:00
parent 1ef05855eb
commit cb6721ce3e
17 changed files with 145 additions and 134 deletions

View File

@@ -66,12 +66,13 @@
],
"scripts": {
"build": "unbuild",
"dev": "pnpm play:node",
"lint": "eslint --cache --ext .ts,.js,.mjs,.cjs . && prettier -c src",
"lint:fix": "eslint --cache --ext .ts,.js,.mjs,.cjs . --fix && prettier -c src -w",
"prepack": "pnpm run build",
"play:bun": "bun playground/bun.ts",
"play:cf": "wrangler dev --port 3001",
"play:deno": "deno run -A playground/deno.ts",
"play:deno": "deno run --unstable-sloppy-imports -A playground/deno.ts",
"play:node": "jiti playground/node.ts",
"play:uws": "jiti playground/uws.ts",
"release": "pnpm test && changelogen --release && npm publish && git push --follow-tags",

View File

@@ -1,56 +0,0 @@
import type { WebSocketHooks, WebSocketAdapter } from "../src";
export const getIndexHTML = (params) =>
import("../examples/h3/index.html.ts").then((r) => r.html(params));
export function createDemo<T extends WebSocketAdapter>(
adapter: T,
opts?: Parameters<T>[1],
): ReturnType<T> {
const hooks = createWebSocketDebugHooks({
open(peer) {
peer.send(`Hello!`);
peer.send(
JSON.stringify(
{
url: peer.url,
headers: peer.headers && Object.fromEntries(peer.headers),
},
undefined,
2,
),
);
},
message(peer, message) {
if (message.text() === "ping") {
peer.send("pong");
}
},
});
return adapter(hooks, opts);
}
function createWebSocketDebugHooks(
hooks: Partial<WebSocketHooks>,
): Partial<WebSocketHooks> {
const createDebugHook =
(name: keyof WebSocketHooks) =>
(peer, ...args: any[]) => {
console.log(
`[ws] [${name}]`,
peer,
[...args].map((arg, i) => `\n - arg#${i} ${arg}`).join(""),
);
hooks[name]?.(peer, ...args);
};
return new Proxy(
{},
{
get(_, prop) {
return createDebugHook(prop as keyof WebSocketHooks);
},
},
);
}

59
playground/_shared.ts Normal file
View File

@@ -0,0 +1,59 @@
import {
CrossWSOptions,
WebSocketAdapter,
defineWebSocketHooks,
} from "../src/index.ts";
export const getIndexHTML = (params) =>
import("../examples/h3/index.html.ts").then((r) => r.html(params));
export function createDemo<T extends WebSocketAdapter>(
adapter: T,
opts?: Parameters<T>[1],
): ReturnType<T> {
const hooks = defineWebSocketHooks({
$(name, peer, ...args) {
console.log(
`$ ${peer} ${name} (${args.map((arg) => stringify(arg)).join(", ")})`,
);
},
open(peer) {
peer.send(`Hello ${peer}`);
},
message(peer, message) {
if (message.text() === "ping") {
peer.send("pong");
}
},
});
const resolve: CrossWSOptions["resolve"] = (peer) => {
return {
open: () => {
peer.send(
JSON.stringify(
{
url: peer.url,
headers: peer.headers && Object.fromEntries(peer.headers),
},
undefined,
2,
),
);
},
};
};
return adapter(hooks, {
resolve,
...opts,
});
}
function stringify(val) {
const str = val.toString();
if (str === "[object Object]") {
return val.constructor?.name || "??";
}
return str;
}

View File

@@ -1,7 +1,7 @@
// You can run this demo using `bun --bun ./bun.ts` or `npm run play:bun` in repo
import bunAdapter from "../src/adapters/bun";
import { createDemo, getIndexHTML } from "./_common";
import { createDemo, getIndexHTML } from "./_shared";
const adapter = createDemo(bunAdapter);

View File

@@ -1,7 +1,7 @@
// You can run this demo using `npm run play:cf` in repo
import type { Request, ExecutionContext } from "@cloudflare/workers-types";
import cloudflareAdapter from "../src/adapters/cloudflare";
import { createDemo, getIndexHTML } from "./_common.ts";
import { createDemo, getIndexHTML } from "./_shared.ts";
const { handleUpgrade } = createDemo(cloudflareAdapter);

View File

@@ -1,11 +1,11 @@
// You can run this demo using `deno run -A ./deno.ts` or `npm run play:deno` in repo
import denoAdapter from "../dist/adapters/deno.mjs";
import denoAdapter from "../src/adapters/deno.ts";
// @ts-ignore
import type * as _Deno from "../types/lib.deno.d.ts";
import { createDemo, getIndexHTML } from "./_common.ts";
import { createDemo, getIndexHTML } from "./_shared.ts";
declare global {
const Deno: typeof import("@deno/types").Deno;

View File

@@ -2,7 +2,7 @@
import { createServer } from "node:http";
import nodeAdapter from "../src/adapters/node";
import { createDemo, getIndexHTML } from "./_common";
import { createDemo, getIndexHTML } from "./_shared";
const adapter = createDemo(nodeAdapter);

View File

@@ -1,8 +1,8 @@
// You can run this demo using `npm run play:node-uws` in repo
import { App } from "uWebSockets.js";
import uwsAdapter from "../src/adapters/uws.ts";
import { createDemo, getIndexHTML } from "./_common.ts";
import uwsAdapter from "../src/adapters/uws";
import { createDemo, getIndexHTML } from "./_shared.ts";
const adapter = createDemo(uwsAdapter);

View File

@@ -4,7 +4,7 @@ import type { WebSocketHandler, ServerWebSocket, Server } from "bun";
import { WebSocketMessage } from "../message";
import { WebSocketError } from "../error";
import { WebSocketPeerBase } from "../peer";
import { WebSocketPeer } from "../peer";
import { defineWebSocketAdapter } from "../adapter";
import { CrossWSOptions, createCrossWS } from "../crossws";
@@ -28,7 +28,7 @@ export default defineWebSocketAdapter<Adapter, AdapterOptions>(
if (ws.data?._peer) {
return ws.data._peer;
}
const peer = new WebSocketPeer({ bun: { ws } });
const peer = new BunPeer({ bun: { ws } });
ws.data = ws.data || {};
ws.data._peer = peer;
return peer;
@@ -79,7 +79,7 @@ export default defineWebSocketAdapter<Adapter, AdapterOptions>(
},
);
class WebSocketPeer extends WebSocketPeerBase<{
class BunPeer extends WebSocketPeer<{
bun: { ws: ServerWebSocket<ContextData> };
}> {
get id() {

View File

@@ -2,7 +2,7 @@
import type * as _cf from "@cloudflare/workers-types";
import { WebSocketPeerBase } from "../peer";
import { WebSocketPeer } from "../peer";
import { defineWebSocketAdapter } from "../adapter.js";
import { WebSocketMessage } from "../message";
import { WebSocketError } from "../error";
@@ -36,14 +36,14 @@ export default defineWebSocketAdapter<Adapter, AdapterOptions>(
const client = pair[0];
const server = pair[1];
const peer = new CloudflareWebSocketPeer({
const peer = new CloudflarePeer({
cloudflare: { client, server, req, env, context },
});
server.accept();
crossws.$("cloudflare:accept", peer);
hooks.open?.(peer);
crossws.open(peer);
server.addEventListener("message", (event) => {
crossws.$("cloudflare:message", peer, event);
@@ -73,7 +73,7 @@ export default defineWebSocketAdapter<Adapter, AdapterOptions>(
},
);
class CloudflareWebSocketPeer extends WebSocketPeerBase<{
class CloudflarePeer extends WebSocketPeer<{
cloudflare: {
client: _cf.WebSocket;
server: _cf.WebSocket;

View File

@@ -4,7 +4,7 @@
import { WebSocketMessage } from "../message";
import { WebSocketError } from "../error";
import { WebSocketPeerBase } from "../peer";
import { WebSocketPeer } from "../peer";
import { defineWebSocketAdapter } from "../adapter.js";
import { CrossWSOptions, createCrossWS } from "../crossws";
@@ -24,12 +24,12 @@ export default defineWebSocketAdapter<Adapter, AdapterOptions>(
const handleUpgrade = (req: Request) => {
const upgrade = Deno.upgradeWebSocket(req);
const peer = new DenoWebSocketPeer({
const peer = new DenoPeer({
deno: { ws: upgrade.socket, req },
});
upgrade.socket.addEventListener("open", () => {
crossws.$("deno:open", peer);
hooks.open?.(peer);
crossws.open(peer);
});
upgrade.socket.addEventListener("message", (event) => {
crossws.$("deno:message", peer, event);
@@ -52,7 +52,7 @@ export default defineWebSocketAdapter<Adapter, AdapterOptions>(
},
);
class DenoWebSocketPeer extends WebSocketPeerBase<{
class DenoPeer extends WebSocketPeer<{
deno: { ws: any; req: Request };
}> {
get id() {

View File

@@ -10,7 +10,7 @@ import type {
WebSocketServer,
WebSocket as WebSocketT,
} from "../../types/ws";
import { WebSocketPeerBase } from "../peer";
import { WebSocketPeer } from "../peer";
import { WebSocketMessage } from "../message";
import { WebSocketError } from "../error";
import { defineWebSocketAdapter } from "../adapter";
@@ -37,22 +37,23 @@ export default defineWebSocketAdapter<Adapter, AdapterOptions>(
}) as WebSocketServer);
// Unmanaged server-level events
wss.on("error", (error) => {
crossws.$("node:server-error", error);
});
wss.on("headers", (headers, request) => {
crossws.$("node:server-headers", headers, request);
});
wss.on("listening", () => {
crossws.$("node:server-listening");
});
wss.on("close", () => {
crossws.$("node:server-close");
});
// TODO: Expose with new API
// wss.on("error", (error) => {
// crossws.$("node:server-error", error);
// });
// wss.on("headers", (headers, request) => {
// crossws.$("node:server-headers", headers, request);
// });
// wss.on("listening", () => {
// crossws.$("node:server-listening");
// });
// wss.on("close", () => {
// crossws.$("node:server-close");
// });
wss.on("connection", (ws, req) => {
const peer = new NodeWebSocketPeer({ node: { ws, req, server: wss } });
hooks.open?.(peer);
const peer = new NodePeer({ node: { ws, req, server: wss } });
crossws.open(peer);
// Managed socket-level events
ws.on("message", (data: RawData, isBinary: boolean) => {
@@ -105,7 +106,7 @@ export default defineWebSocketAdapter<Adapter, AdapterOptions>(
},
);
class NodeWebSocketPeer extends WebSocketPeerBase<{
class NodePeer extends WebSocketPeer<{
node: {
server: WebSocketServer;
req: IncomingMessage;

View File

@@ -7,7 +7,7 @@ import type {
HttpRequest,
HttpResponse,
} from "uWebSockets.js";
import { WebSocketPeerBase } from "../peer";
import { WebSocketPeer } from "../peer";
import { WebSocketMessage } from "../message";
import { defineWebSocketAdapter } from "../adapter";
import { CrossWSOptions, createCrossWS } from "../crossws";
@@ -48,7 +48,7 @@ export default defineWebSocketAdapter<Adapter, AdapterOptions>(
if (userData._peer) {
return userData._peer as WebSocketPeer;
}
const peer = new WebSocketPeer({ uws: { ws, userData } });
const peer = new UWSPeer({ uws: { ws, userData } });
userData._peer = peer;
return peer;
};
@@ -73,7 +73,7 @@ export default defineWebSocketAdapter<Adapter, AdapterOptions>(
open(ws) {
const peer = getPeer(ws);
crossws.$("uws:open", peer, ws);
hooks.open?.(peer);
crossws.open(peer);
},
ping(ws, message) {
const peer = getPeer(ws);
@@ -110,18 +110,21 @@ export default defineWebSocketAdapter<Adapter, AdapterOptions>(
},
);
class WebSocketPeer extends WebSocketPeerBase<{
class UWSPeer extends WebSocketPeer<{
uws: {
ws: WebSocket<UserData>;
userData: UserData;
};
}> {
_headers: Headers | undefined;
_decoder = new TextDecoder();
get id() {
try {
const addr = this.ctx.uws.ws?.getRemoteAddressAsText();
return new TextDecoder().decode(addr);
const addr = this._decoder.decode(
this.ctx.uws.ws?.getRemoteAddressAsText(),
);
return addr.replace(/(0000:)+/, "");
} catch {
// Error: Invalid access of closed uWS.WebSocket/SSLWebSocket.
}

View File

@@ -1,37 +1,46 @@
import type { WebSocketHooks, AdapterHooks, UserHooks } from "./hooks";
import { WebSocketPeer } from "./peer";
export interface CrossWSOptions {}
export interface CrossWS extends WebSocketHooks {}
type AdapterHook = <NAME extends keyof AdapterHooks>(
name: NAME,
...args: Parameters<AdapterHooks[NAME]>
) => ReturnType<AdapterHooks[NAME]>;
export interface CrossWS extends WebSocketHooks {
$: AdapterHook;
export interface CrossWSOptions {
resolve?: (
peer: WebSocketPeer,
) => UserHooks | void | Promise<UserHooks | void>;
}
export function createCrossWS(
_hooks: UserHooks,
options: CrossWSOptions,
): CrossWS {
const _callHook = options.resolve
? async (name: keyof UserHooks, peer: WebSocketPeer, ...args: any[]) => {
const hooks = await options.resolve?.(peer);
// @ts-expect-error
return hooks?.[name]?.(peer, ...args);
}
: undefined;
return {
// @ts-expect-error TODO
$(name, ...args) {
// @ts-expect-error TODO
return _hooks[name]?.(...args);
$(name, peer, ...args) {
_hooks.$?.(name, peer, ...args);
_callHook?.(name, peer, ...args);
},
message(peer, message) {
return _hooks.message?.(peer, message);
_hooks.message?.(peer, message);
_callHook?.("message", peer, message);
},
open(peer) {
return _hooks.open?.(peer);
_hooks.open?.(peer);
_callHook?.("open", peer);
},
close(peer, { code, reason }) {
return _hooks.close?.(peer, { code, reason });
_hooks.close?.(peer, { code, reason });
_callHook?.("close", peer, { code, reason });
},
error(peer, error) {
return _hooks.error?.(peer, error);
_hooks.error?.(peer, error);
_callHook?.("error", peer, error);
},
};
}

View File

@@ -1,15 +1,15 @@
import { WebSocketError } from "./error";
import type { WebSocketMessage } from "./message";
import type { WebSocketPeerBase } from "./peer";
import type { WebSocketPeer } from "./peer";
type WSHook<ArgsT extends Array<any> = []> = (
peer: WebSocketPeerBase,
peer: WebSocketPeer,
...args: ArgsT
) => void | Promise<void>;
type WSGlobalHook<ArgsT extends Array<any> = []> = (
...args: ArgsT
) => void | Promise<void>;
// type WSGlobalHook<ArgsT extends Array<any> = []> = (
// ...args: ArgsT
// ) => void | Promise<void>;
export type UserHooks = Partial<WebSocketHooks & AdapterHooks>;
@@ -20,6 +20,9 @@ export function defineWebSocketHooks<T extends UserHooks = UserHooks>(
}
export interface WebSocketHooks {
/** Catch-all handler */
$: (name: keyof UserHooks, peer: WebSocketPeer, ...args: any[]) => void;
/** A message is received */
message: WSHook<[WebSocketMessage]>;
@@ -64,10 +67,10 @@ export interface AdapterHooks {
"node:pong": WSHook<[data: Buffer]>;
"node:unexpected-response": WSHook<[req: any, res: any]>;
"node:upgrade": WSHook<[req: any]>;
"node:server-error": WSGlobalHook<[error: any]>;
"node:server-listening": WSGlobalHook<[]>;
"node:server-close": WSGlobalHook<[]>;
"node:server-headers": WSGlobalHook<[headers: any, request: any]>;
// "node:server-error": WSGlobalHook<[error: any]>;
// "node:server-listening": WSGlobalHook<[]>;
// "node:server-close": WSGlobalHook<[]>;
// "node:server-headers": WSGlobalHook<[headers: any, request: any]>;
// uws (Node)
"uws:open": WSHook<[ws: any]>;

View File

@@ -8,11 +8,11 @@ const ReadyStateMap = {
3: "closed",
} as const;
export abstract class WebSocketPeerBase<AdapterContext = any> {
export abstract class WebSocketPeer<AdapterContext = any> {
constructor(public ctx: AdapterContext) {}
get id(): string | undefined {
return undefined;
return "??";
}
get url(): string {
@@ -33,8 +33,7 @@ export abstract class WebSocketPeerBase<AdapterContext = any> {
): number;
toString() {
const readyState = ReadyStateMap[this.readyState];
return `<WebSocketPeer${this.id ? ` ${this.id}` : ""} (${readyState})>`;
return `${this.id || ""}${this.readyState === 1 ? "" : ` [${ReadyStateMap[this.readyState]}]`}`;
}
[Symbol.for("nodejs.util.inspect.custom")]() {

View File

@@ -1,8 +0,0 @@
import { expect, it, describe } from "vitest";
import {} from "../src";
describe("crossws", () => {
it.todo("pass", () => {
expect(true).toBe(true);
});
});