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": { "scripts": {
"build": "unbuild", "build": "unbuild",
"dev": "pnpm play:node",
"lint": "eslint --cache --ext .ts,.js,.mjs,.cjs . && prettier -c src", "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", "lint:fix": "eslint --cache --ext .ts,.js,.mjs,.cjs . --fix && prettier -c src -w",
"prepack": "pnpm run build", "prepack": "pnpm run build",
"play:bun": "bun playground/bun.ts", "play:bun": "bun playground/bun.ts",
"play:cf": "wrangler dev --port 3001", "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:node": "jiti playground/node.ts",
"play:uws": "jiti playground/uws.ts", "play:uws": "jiti playground/uws.ts",
"release": "pnpm test && changelogen --release && npm publish && git push --follow-tags", "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 // You can run this demo using `bun --bun ./bun.ts` or `npm run play:bun` in repo
import bunAdapter from "../src/adapters/bun"; import bunAdapter from "../src/adapters/bun";
import { createDemo, getIndexHTML } from "./_common"; import { createDemo, getIndexHTML } from "./_shared";
const adapter = createDemo(bunAdapter); const adapter = createDemo(bunAdapter);

View File

@@ -1,7 +1,7 @@
// You can run this demo using `npm run play:cf` in repo // You can run this demo using `npm run play:cf` in repo
import type { Request, ExecutionContext } from "@cloudflare/workers-types"; import type { Request, ExecutionContext } from "@cloudflare/workers-types";
import cloudflareAdapter from "../src/adapters/cloudflare"; import cloudflareAdapter from "../src/adapters/cloudflare";
import { createDemo, getIndexHTML } from "./_common.ts"; import { createDemo, getIndexHTML } from "./_shared.ts";
const { handleUpgrade } = createDemo(cloudflareAdapter); 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 // 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 // @ts-ignore
import type * as _Deno from "../types/lib.deno.d.ts"; import type * as _Deno from "../types/lib.deno.d.ts";
import { createDemo, getIndexHTML } from "./_common.ts"; import { createDemo, getIndexHTML } from "./_shared.ts";
declare global { declare global {
const Deno: typeof import("@deno/types").Deno; const Deno: typeof import("@deno/types").Deno;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,37 +1,46 @@
import type { WebSocketHooks, AdapterHooks, UserHooks } from "./hooks"; 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>( export interface CrossWSOptions {
name: NAME, resolve?: (
...args: Parameters<AdapterHooks[NAME]> peer: WebSocketPeer,
) => ReturnType<AdapterHooks[NAME]>; ) => UserHooks | void | Promise<UserHooks | void>;
export interface CrossWS extends WebSocketHooks {
$: AdapterHook;
} }
export function createCrossWS( export function createCrossWS(
_hooks: UserHooks, _hooks: UserHooks,
options: CrossWSOptions, options: CrossWSOptions,
): CrossWS { ): 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 { return {
// @ts-expect-error TODO $(name, peer, ...args) {
$(name, ...args) { _hooks.$?.(name, peer, ...args);
// @ts-expect-error TODO _callHook?.(name, peer, ...args);
return _hooks[name]?.(...args);
}, },
message(peer, message) { message(peer, message) {
return _hooks.message?.(peer, message); _hooks.message?.(peer, message);
_callHook?.("message", peer, message);
}, },
open(peer) { open(peer) {
return _hooks.open?.(peer); _hooks.open?.(peer);
_callHook?.("open", peer);
}, },
close(peer, { code, reason }) { close(peer, { code, reason }) {
return _hooks.close?.(peer, { code, reason }); _hooks.close?.(peer, { code, reason });
_callHook?.("close", peer, { code, reason });
}, },
error(peer, error) { 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 { WebSocketError } from "./error";
import type { WebSocketMessage } from "./message"; import type { WebSocketMessage } from "./message";
import type { WebSocketPeerBase } from "./peer"; import type { WebSocketPeer } from "./peer";
type WSHook<ArgsT extends Array<any> = []> = ( type WSHook<ArgsT extends Array<any> = []> = (
peer: WebSocketPeerBase, peer: WebSocketPeer,
...args: ArgsT ...args: ArgsT
) => void | Promise<void>; ) => void | Promise<void>;
type WSGlobalHook<ArgsT extends Array<any> = []> = ( // type WSGlobalHook<ArgsT extends Array<any> = []> = (
...args: ArgsT // ...args: ArgsT
) => void | Promise<void>; // ) => void | Promise<void>;
export type UserHooks = Partial<WebSocketHooks & AdapterHooks>; export type UserHooks = Partial<WebSocketHooks & AdapterHooks>;
@@ -20,6 +20,9 @@ export function defineWebSocketHooks<T extends UserHooks = UserHooks>(
} }
export interface WebSocketHooks { export interface WebSocketHooks {
/** Catch-all handler */
$: (name: keyof UserHooks, peer: WebSocketPeer, ...args: any[]) => void;
/** A message is received */ /** A message is received */
message: WSHook<[WebSocketMessage]>; message: WSHook<[WebSocketMessage]>;
@@ -64,10 +67,10 @@ export interface AdapterHooks {
"node:pong": WSHook<[data: Buffer]>; "node:pong": WSHook<[data: Buffer]>;
"node:unexpected-response": WSHook<[req: any, res: any]>; "node:unexpected-response": WSHook<[req: any, res: any]>;
"node:upgrade": WSHook<[req: any]>; "node:upgrade": WSHook<[req: any]>;
"node:server-error": WSGlobalHook<[error: any]>; // "node:server-error": WSGlobalHook<[error: any]>;
"node:server-listening": WSGlobalHook<[]>; // "node:server-listening": WSGlobalHook<[]>;
"node:server-close": WSGlobalHook<[]>; // "node:server-close": WSGlobalHook<[]>;
"node:server-headers": WSGlobalHook<[headers: any, request: any]>; // "node:server-headers": WSGlobalHook<[headers: any, request: any]>;
// uws (Node) // uws (Node)
"uws:open": WSHook<[ws: any]>; "uws:open": WSHook<[ws: any]>;

View File

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