first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
/**
* @wrnexus/pubsub — topic-based publish/subscribe with a pluggable driver.
* The default is in-process; swap in a Redis/NATS driver for cross-instance
* messaging (it also backs @wrnexus/core's realtime bridge).
*
* const bus = createPubSub();
* const off = bus.subscribe("order:*", (msg, topic) => {...});
* await bus.publish("order:created", { id: 7 });
*
* Subscriptions match exact topics, "ns:*" prefixes, and "*" (everything).
*/
export type Handler<T = unknown> = (message: T, topic: string) => void | Promise<void>;
export interface PubSubDriver {
publish(topic: string, message: unknown): void | Promise<void>;
subscribe(pattern: string, handler: Handler): () => void;
}
export interface PubSub {
publish<T = unknown>(topic: string, message: T): Promise<void>;
subscribe<T = unknown>(pattern: string, handler: Handler<T>): () => void;
}
function patternMatches(pattern: string, topic: string): boolean {
if (pattern === "*" || pattern === topic) return true;
if (pattern.endsWith(":*")) return topic.startsWith(pattern.slice(0, -1)); // "post:" prefix
return false;
}
/** In-process pub/sub driver (default). */
export function memoryDriver(): PubSubDriver {
const subs = new Map<string, Set<Handler>>();
return {
publish(topic, message) {
for (const [pattern, handlers] of subs) {
if (!patternMatches(pattern, topic)) continue;
for (const handler of handlers) void handler(message, topic);
}
},
subscribe(pattern, handler) {
let set = subs.get(pattern);
if (!set) subs.set(pattern, (set = new Set()));
set.add(handler);
return () => {
set!.delete(handler);
if (!set!.size) subs.delete(pattern);
};
},
};
}
/** Create a pub/sub bus over a driver (in-memory by default). */
export function createPubSub(driver: PubSubDriver = memoryDriver()): PubSub {
return {
async publish(topic, message) {
await driver.publish(topic, message);
},
subscribe(pattern, handler) {
return driver.subscribe(pattern, handler as Handler);
},
};
}
+182
View File
@@ -0,0 +1,182 @@
/**
* Redis-backed pub/sub driver — lets pub/sub messages cross **processes**, so
* multiple WrNexus app runs (or apps on different domains) can communicate. Uses a
* self-contained RESP client over a raw TCP socket (`Bun.connect`), so it adds no
* npm dependency. It also backs @wrnexus/core's realtime bridge for horizontal
* scaling.
*
* import { createPubSub } from "@wrnexus/pubsub";
* import { redisDriver } from "@wrnexus/pubsub/redis";
*
* const bus = createPubSub(redisDriver("redis://localhost:6379"));
* bus.subscribe("order:*", (msg, topic) => { ... }); // any app process receives it
* await bus.publish("order:created", { id: 7 });
*
* Pattern mapping: an exact topic uses Redis SUBSCRIBE; a wildcard pattern
* (`ns:*` or `*`) uses PSUBSCRIBE, whose glob semantics match this library's.
*/
import type { Handler, PubSubDriver } from "./index.ts";
import { concat, encodeCommand, parseReply, type RespValue } from "./resp.ts";
interface RedisConn {
send(bytes: Uint8Array): void;
close(): void;
}
interface ParsedUrl {
host: string;
port: number;
password?: string;
db?: number;
tls: boolean;
}
function parseUrl(url: string): ParsedUrl {
const u = new URL(url);
if (u.protocol !== "redis:" && u.protocol !== "rediss:") {
throw new TypeError("Redis URL must use redis:// or rediss://");
}
const path = u.pathname.replace(/^\//, "");
const port = u.port ? Number(u.port) : 6379;
const db = path ? Number(path) : undefined;
if (!Number.isInteger(port) || port < 1 || port > 65_535)
throw new TypeError("Redis URL has an invalid port");
if (db !== undefined && (!Number.isInteger(db) || db < 0))
throw new TypeError("Redis URL database must be a non-negative integer");
return {
host: u.hostname || "localhost",
port,
password: u.password ? decodeURIComponent(u.password) : undefined,
db,
tls: u.protocol === "rediss:",
};
}
const isPattern = (p: string): boolean => p.includes("*");
/**
* Open a Redis TCP connection. `onReply` receives every parsed top-level reply
* (used by the subscriber connection to dispatch message/pmessage pushes).
*/
function connect(cfg: ParsedUrl, onReply?: (value: RespValue) => void): RedisConn {
let socket: { write(data: Uint8Array): void; end(): void } | null = null;
const pending: Uint8Array[] = [];
const maxPending = 1000;
let buf: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
// Bun.connect is available in the Bun runtime.
const Bun = (globalThis as { Bun?: { connect: (opts: unknown) => Promise<unknown> } }).Bun;
if (!Bun?.connect) throw new Error("redisDriver requires the Bun runtime (Bun.connect).");
void Bun.connect({
hostname: cfg.host,
port: cfg.port,
tls: cfg.tls,
socket: {
open(s: { write(data: Uint8Array): void; end(): void }) {
socket = s;
if (cfg.password) s.write(encodeCommand(["AUTH", cfg.password]));
if (cfg.db) s.write(encodeCommand(["SELECT", String(cfg.db)]));
for (const p of pending) s.write(p);
pending.length = 0;
},
data(_s: unknown, chunk: Uint8Array) {
buf = buf.length ? concat([buf, chunk]) : chunk;
for (;;) {
const r = parseReply(buf);
if (!r) break;
buf = buf.slice(r.next);
onReply?.(r.value);
}
},
error() {
/* connection error — writes silently no-op until reconnect */
},
close() {
socket = null;
},
},
});
return {
send(bytes) {
if (socket) socket.write(bytes);
else {
if (pending.length >= maxPending) {
throw new Error("Redis connection is unavailable and its pending write queue is full");
}
pending.push(bytes);
}
},
close() {
socket?.end();
},
};
}
/**
* A cross-process pub/sub driver backed by Redis. `url` defaults to
* `$REDIS_URL` or `redis://localhost:6379`.
*/
export function redisDriver(url?: string): PubSubDriver & { close(): void } {
const cfg = parseUrl(url ?? getEnv("REDIS_URL") ?? "redis://localhost:6379");
const subs = new Map<string, Set<Handler>>();
const redisSubscribed = new Set<string>();
const dispatch = (key: string, topic: string, payload: string) => {
const handlers = subs.get(key);
if (!handlers) return;
let message: unknown = payload;
try {
message = JSON.parse(payload);
} catch {
/* not JSON — deliver the raw string */
}
for (const handler of handlers) void handler(message, topic);
};
const subConn = connect(cfg, (value) => {
if (!Array.isArray(value)) return;
const kind = value[0];
if (kind === "message") dispatch(String(value[1]), String(value[1]), String(value[2]));
else if (kind === "pmessage") dispatch(String(value[1]), String(value[2]), String(value[3]));
});
const pubConn = connect(cfg);
return {
publish(topic, message) {
const payload = typeof message === "string" ? message : JSON.stringify(message);
pubConn.send(encodeCommand(["PUBLISH", topic, payload]));
},
subscribe(pattern, handler) {
let set = subs.get(pattern);
if (!set) subs.set(pattern, (set = new Set()));
set.add(handler);
if (!redisSubscribed.has(pattern)) {
redisSubscribed.add(pattern);
subConn.send(encodeCommand([isPattern(pattern) ? "PSUBSCRIBE" : "SUBSCRIBE", pattern]));
}
return () => {
set!.delete(handler);
if (!set!.size) {
subs.delete(pattern);
redisSubscribed.delete(pattern);
subConn.send(
encodeCommand([isPattern(pattern) ? "PUNSUBSCRIBE" : "UNSUBSCRIBE", pattern]),
);
}
};
},
close() {
subConn.close();
pubConn.close();
},
};
}
function getEnv(key: string): string | undefined {
return (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[
key
];
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Minimal RESP (REdis Serialization Protocol) codec — just enough to drive
* Redis pub/sub over a raw TCP socket, with no external client dependency.
*
* Encodes commands as arrays of bulk strings, and streaming-parses the replies
* we care about: simple strings (+), errors (-), integers (:), bulk strings ($),
* and arrays (*) — which is what SUBSCRIBE/PSUBSCRIBE confirmations and
* message/pmessage pushes are made of.
*/
export type RespValue = string | number | null | RespValue[];
const encoder = new TextEncoder();
const decoder = new TextDecoder();
/** Encode a command (`["PUBLISH", topic, payload]`) as a RESP array of bulk strings. */
export function encodeCommand(args: string[]): Uint8Array {
let head = `*${args.length}\r\n`;
const parts: Uint8Array[] = [];
for (const arg of args) {
const bytes = encoder.encode(arg);
head += `$${bytes.length}\r\n`;
parts.push(encoder.encode(head), bytes, encoder.encode("\r\n"));
head = "";
}
return concat(parts);
}
/** Concatenate byte arrays. */
export function concat(parts: Uint8Array[]): Uint8Array {
let len = 0;
for (const p of parts) len += p.length;
const out = new Uint8Array(len);
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
function indexOfCRLF(buf: Uint8Array, from: number): number {
for (let i = from; i + 1 < buf.length; i++) {
if (buf[i] === 13 && buf[i + 1] === 10) return i;
}
return -1;
}
/**
* Parse one RESP reply from `buf` at `off`. Returns the value and the offset just
* past it, or `null` if the buffer doesn't yet hold a complete reply (caller
* should wait for more bytes).
*/
export function parseReply(buf: Uint8Array, off = 0): { value: RespValue; next: number } | null {
if (off >= buf.length) return null;
const type = buf[off];
const lineEnd = indexOfCRLF(buf, off + 1);
if (lineEnd === -1) return null;
const line = decoder.decode(buf.subarray(off + 1, lineEnd));
const after = lineEnd + 2;
switch (type) {
case 43: // '+' simple string
case 45: // '-' error
return { value: line, next: after };
case 58: // ':' integer
return { value: Number(line), next: after };
case 36: {
// '$' bulk string
const len = Number(line);
if (len === -1) return { value: null, next: after };
if (after + len + 2 > buf.length) return null;
return { value: decoder.decode(buf.subarray(after, after + len)), next: after + len + 2 };
}
case 42: {
// '*' array
const count = Number(line);
if (count === -1) return { value: null, next: after };
const arr: RespValue[] = [];
let cur = after;
for (let i = 0; i < count; i++) {
const r = parseReply(buf, cur);
if (!r) return null;
arr.push(r.value);
cur = r.next;
}
return { value: arr, next: cur };
}
default:
return null;
}
}