weifuwu 0.16.2 → 0.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -2
- package/dist/hub.d.ts +12 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +93 -48
- package/dist/messager/types.d.ts +2 -0
- package/dist/messager/ws.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -151,6 +151,7 @@ All use the same pattern — `const m = module(options)` → `app.use('/path', m
|
|
|
151
151
|
| `embed()` / `embedMany()` | AI SDK — text embeddings |
|
|
152
152
|
| `smoothStream()` | AI SDK — smooth streaming middleware |
|
|
153
153
|
| `openai` / `createOpenAI()` | OpenAI provider for AI SDK |
|
|
154
|
+
| `createHub(options?)` | WebSocket channel hub — `join()`, `leave()`, `broadcast()` with optional Redis pub/sub |
|
|
154
155
|
|
|
155
156
|
---
|
|
156
157
|
|
|
@@ -307,6 +308,39 @@ const server = serve(app.handler(), {
|
|
|
307
308
|
})
|
|
308
309
|
```
|
|
309
310
|
|
|
311
|
+
### Cross-process WebSocket with `createHub`
|
|
312
|
+
|
|
313
|
+
Use `createHub()` to group WebSocket connections into named channels and broadcast to them — works within a single process, and with optional Redis pub/sub across multiple Node.js processes behind a load balancer.
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
import { createHub, serve, Router } from 'weifuwu'
|
|
317
|
+
import { redis } from 'weifuwu'
|
|
318
|
+
|
|
319
|
+
const hub = createHub({ redis }) // omit redis for in-process only
|
|
320
|
+
|
|
321
|
+
const app = new Router().ws('/chat/:room', {
|
|
322
|
+
open(ws, ctx) {
|
|
323
|
+
hub.join(`room:${ctx.params.room}`, ws)
|
|
324
|
+
},
|
|
325
|
+
message(ws, ctx, data) {
|
|
326
|
+
hub.broadcast(`room:${ctx.params.room}`, {
|
|
327
|
+
user: ctx.user?.id,
|
|
328
|
+
text: data.toString(),
|
|
329
|
+
})
|
|
330
|
+
},
|
|
331
|
+
close(ws) {
|
|
332
|
+
hub.leave(ws)
|
|
333
|
+
},
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
serve(app.handler(), {
|
|
337
|
+
port: 3000,
|
|
338
|
+
websocket: app.websocketHandler(),
|
|
339
|
+
})
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
With Redis configured, `hub.broadcast()` publishes to a Redis channel — all processes subscribed via `createHub({ redis })` receive and forward to their local WebSocket connections.
|
|
343
|
+
|
|
310
344
|
---
|
|
311
345
|
|
|
312
346
|
# Middleware
|
|
@@ -1326,10 +1360,14 @@ All routes require `ctx.tenant` (set by `t.middleware()`). All queries automatic
|
|
|
1326
1360
|
Real-time chat with channels, WebSocket, and agent routing.
|
|
1327
1361
|
|
|
1328
1362
|
```ts
|
|
1329
|
-
import { messager, agent } from 'weifuwu'
|
|
1363
|
+
import { messager, agent, redis } from 'weifuwu'
|
|
1330
1364
|
|
|
1331
1365
|
const agents = agent({ pg })
|
|
1332
|
-
|
|
1366
|
+
|
|
1367
|
+
// Single process (no cross-process broadcast):
|
|
1368
|
+
// const msg = messager({ pg, agents })
|
|
1369
|
+
// Multi-process (Redis pub/sub broadcast):
|
|
1370
|
+
const msg = messager({ pg, agents, redis: redis() })
|
|
1333
1371
|
|
|
1334
1372
|
await msg.migrate()
|
|
1335
1373
|
app.use('/api', msg.router())
|
package/dist/hub.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Redis } from './vendor.ts';
|
|
2
|
+
export interface HubOptions {
|
|
3
|
+
redis?: Redis;
|
|
4
|
+
prefix?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface Hub {
|
|
7
|
+
join(key: string, ws: WebSocket): void;
|
|
8
|
+
leave(ws: WebSocket): void;
|
|
9
|
+
broadcast(key: string, data: unknown): void;
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export declare function createHub(opts?: HubOptions): Hub;
|
package/dist/index.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export { user } from './user/index.ts';
|
|
|
38
38
|
export type { UserOptions, UserData, UserModule, OAuth2Client, } from './user/types.ts';
|
|
39
39
|
export { redis } from './redis/index.ts';
|
|
40
40
|
export type { RedisOptions, RedisClient } from './redis/types.ts';
|
|
41
|
+
export { createHub } from './hub.ts';
|
|
42
|
+
export type { Hub, HubOptions } from './hub.ts';
|
|
41
43
|
export { queue } from './queue/index.ts';
|
|
42
44
|
export type { QueueOptions, QueueJob, Queue } from './queue/types.ts';
|
|
43
45
|
export { tenant } from './tenant/index.ts';
|
package/dist/index.js
CHANGED
|
@@ -3465,6 +3465,62 @@ function redis(opts) {
|
|
|
3465
3465
|
return mw;
|
|
3466
3466
|
}
|
|
3467
3467
|
|
|
3468
|
+
// hub.ts
|
|
3469
|
+
function createHub(opts) {
|
|
3470
|
+
const prefix = opts?.prefix ?? "hub:";
|
|
3471
|
+
const channels2 = /* @__PURE__ */ new Map();
|
|
3472
|
+
let redisPub;
|
|
3473
|
+
let redisSub = null;
|
|
3474
|
+
if (opts?.redis) {
|
|
3475
|
+
redisPub = opts.redis;
|
|
3476
|
+
redisSub = opts.redis.duplicate();
|
|
3477
|
+
redisSub.on("message", (rawChannel, rawData) => {
|
|
3478
|
+
if (!rawChannel.startsWith(prefix)) return;
|
|
3479
|
+
const key = rawChannel.slice(prefix.length);
|
|
3480
|
+
const members = channels2.get(key);
|
|
3481
|
+
if (!members) return;
|
|
3482
|
+
for (const ws of members) {
|
|
3483
|
+
try {
|
|
3484
|
+
ws.send(rawData);
|
|
3485
|
+
} catch {
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
});
|
|
3489
|
+
}
|
|
3490
|
+
function join5(key, ws) {
|
|
3491
|
+
if (!channels2.has(key)) {
|
|
3492
|
+
channels2.set(key, /* @__PURE__ */ new Set());
|
|
3493
|
+
redisSub?.subscribe(`${prefix}${key}`);
|
|
3494
|
+
}
|
|
3495
|
+
channels2.get(key).add(ws);
|
|
3496
|
+
}
|
|
3497
|
+
function leave(ws) {
|
|
3498
|
+
for (const [, members] of channels2) {
|
|
3499
|
+
members.delete(ws);
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
function broadcast(key, data) {
|
|
3503
|
+
const msg = JSON.stringify(data);
|
|
3504
|
+
const members = channels2.get(key);
|
|
3505
|
+
if (members) {
|
|
3506
|
+
for (const ws of members) {
|
|
3507
|
+
try {
|
|
3508
|
+
ws.send(msg);
|
|
3509
|
+
} catch {
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
redisPub?.publish(`${prefix}${key}`, msg);
|
|
3514
|
+
}
|
|
3515
|
+
async function close() {
|
|
3516
|
+
channels2.clear();
|
|
3517
|
+
if (redisSub) {
|
|
3518
|
+
await redisSub.quit();
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
return { join: join5, leave, broadcast, close };
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3468
3524
|
// queue/index.ts
|
|
3469
3525
|
import { Redis as IORedis2 } from "ioredis";
|
|
3470
3526
|
import crypto3 from "node:crypto";
|
|
@@ -4809,40 +4865,17 @@ function agent(options) {
|
|
|
4809
4865
|
}
|
|
4810
4866
|
|
|
4811
4867
|
// messager/ws.ts
|
|
4812
|
-
var channels = /* @__PURE__ */ new Map();
|
|
4813
4868
|
var userConnections = /* @__PURE__ */ new Map();
|
|
4869
|
+
var hub;
|
|
4814
4870
|
function broadcastToChannel(channelId, data) {
|
|
4815
|
-
|
|
4816
|
-
if (!members) return;
|
|
4817
|
-
const msg = JSON.stringify(data);
|
|
4818
|
-
for (const { ws } of members) {
|
|
4819
|
-
try {
|
|
4820
|
-
ws.send(msg);
|
|
4821
|
-
} catch {
|
|
4822
|
-
}
|
|
4823
|
-
}
|
|
4824
|
-
}
|
|
4825
|
-
function subscribe(ws, userId, channelId) {
|
|
4826
|
-
if (!channels.has(channelId)) channels.set(channelId, /* @__PURE__ */ new Set());
|
|
4827
|
-
channels.get(channelId).add({ ws, userId });
|
|
4828
|
-
if (!userConnections.has(userId)) userConnections.set(userId, /* @__PURE__ */ new Set());
|
|
4829
|
-
userConnections.get(userId).add(ws);
|
|
4830
|
-
}
|
|
4831
|
-
function unsubscribe(ws) {
|
|
4832
|
-
for (const [, members] of channels) {
|
|
4833
|
-
for (const m of members) {
|
|
4834
|
-
if (m.ws === ws) {
|
|
4835
|
-
members.delete(m);
|
|
4836
|
-
break;
|
|
4837
|
-
}
|
|
4838
|
-
}
|
|
4839
|
-
}
|
|
4840
|
-
for (const [, conns] of userConnections) {
|
|
4841
|
-
conns.delete(ws);
|
|
4842
|
-
}
|
|
4871
|
+
hub?.broadcast(`messager:${channelId}`, data);
|
|
4843
4872
|
}
|
|
4844
4873
|
function createWSHandler(deps) {
|
|
4845
4874
|
const { sql: sql2, agents } = deps;
|
|
4875
|
+
hub = createHub({
|
|
4876
|
+
redis: deps.redis,
|
|
4877
|
+
prefix: "messager:"
|
|
4878
|
+
});
|
|
4846
4879
|
return {
|
|
4847
4880
|
open(ws, ctx) {
|
|
4848
4881
|
const userId = ctx.user?.id;
|
|
@@ -4871,8 +4904,10 @@ function createWSHandler(deps) {
|
|
|
4871
4904
|
RETURNING *
|
|
4872
4905
|
`;
|
|
4873
4906
|
const message = row;
|
|
4907
|
+
hub.join(`messager:${channel_id}`, ws);
|
|
4908
|
+
if (!userConnections.has(userId)) userConnections.set(userId, /* @__PURE__ */ new Set());
|
|
4909
|
+
userConnections.get(userId).add(ws);
|
|
4874
4910
|
broadcastToChannel(channel_id, { type: "message", data: message });
|
|
4875
|
-
subscribe(ws, userId, channel_id);
|
|
4876
4911
|
if (agents) {
|
|
4877
4912
|
const agentMembers = await sql2`
|
|
4878
4913
|
SELECT member_id FROM "_channel_members"
|
|
@@ -4898,7 +4933,9 @@ function createWSHandler(deps) {
|
|
|
4898
4933
|
break;
|
|
4899
4934
|
}
|
|
4900
4935
|
case "typing": {
|
|
4901
|
-
if (channel_id)
|
|
4936
|
+
if (channel_id) {
|
|
4937
|
+
hub.join(`messager:${channel_id}`, ws);
|
|
4938
|
+
}
|
|
4902
4939
|
broadcastToChannel(channel_id, {
|
|
4903
4940
|
type: "typing",
|
|
4904
4941
|
channel_id,
|
|
@@ -4909,7 +4946,7 @@ function createWSHandler(deps) {
|
|
|
4909
4946
|
}
|
|
4910
4947
|
case "read": {
|
|
4911
4948
|
if (!channel_id || !last_message_id) return;
|
|
4912
|
-
|
|
4949
|
+
hub.join(`messager:${channel_id}`, ws);
|
|
4913
4950
|
await sql2`
|
|
4914
4951
|
UPDATE "_channel_members"
|
|
4915
4952
|
SET last_read_id = ${last_message_id}, last_read_at = NOW()
|
|
@@ -4926,22 +4963,28 @@ function createWSHandler(deps) {
|
|
|
4926
4963
|
}
|
|
4927
4964
|
},
|
|
4928
4965
|
close(ws) {
|
|
4929
|
-
|
|
4966
|
+
hub?.leave(ws);
|
|
4967
|
+
for (const [, conns] of userConnections) {
|
|
4968
|
+
conns.delete(ws);
|
|
4969
|
+
}
|
|
4930
4970
|
},
|
|
4931
4971
|
error(ws) {
|
|
4932
|
-
|
|
4972
|
+
hub?.leave(ws);
|
|
4973
|
+
for (const [, conns] of userConnections) {
|
|
4974
|
+
conns.delete(ws);
|
|
4975
|
+
}
|
|
4933
4976
|
}
|
|
4934
4977
|
};
|
|
4935
4978
|
}
|
|
4936
4979
|
|
|
4937
4980
|
// messager/rest.ts
|
|
4938
4981
|
function buildRouter3(deps) {
|
|
4939
|
-
const { sql: sql2, channels:
|
|
4982
|
+
const { sql: sql2, channels: channels2, members, messages: messages2, agents } = deps;
|
|
4940
4983
|
const r = new Router();
|
|
4941
4984
|
r.post("/channels", async (req) => {
|
|
4942
4985
|
const body = await req.json();
|
|
4943
4986
|
if (!body.name) return Response.json({ error: "name is required" }, { status: 400 });
|
|
4944
|
-
const channel = await
|
|
4987
|
+
const channel = await channels2.insert({
|
|
4945
4988
|
name: body.name,
|
|
4946
4989
|
type: body.type || "channel",
|
|
4947
4990
|
created_by: body.created_by || 1
|
|
@@ -4984,14 +5027,14 @@ function buildRouter3(deps) {
|
|
|
4984
5027
|
});
|
|
4985
5028
|
r.get("/channels/:id", async (_req, ctx) => {
|
|
4986
5029
|
const id2 = parseInt(ctx.params.id, 10);
|
|
4987
|
-
const ch = await
|
|
5030
|
+
const ch = await channels2.read(id2);
|
|
4988
5031
|
if (!ch) return Response.json({ error: "Channel not found" }, { status: 404 });
|
|
4989
5032
|
const { data: memberRows } = await members.readMany({ channel_id: id2 });
|
|
4990
5033
|
return Response.json({ channel: ch, members: memberRows });
|
|
4991
5034
|
});
|
|
4992
5035
|
r.delete("/channels/:id", async (_req, ctx) => {
|
|
4993
5036
|
const id2 = parseInt(ctx.params.id, 10);
|
|
4994
|
-
await
|
|
5037
|
+
await channels2.delete(id2);
|
|
4995
5038
|
return Response.json({ ok: true });
|
|
4996
5039
|
});
|
|
4997
5040
|
r.post("/channels/:id/members", async (req, ctx) => {
|
|
@@ -5092,8 +5135,9 @@ function messager(options) {
|
|
|
5092
5135
|
const pg = options.pg;
|
|
5093
5136
|
const sql2 = pg.sql;
|
|
5094
5137
|
const agents = options.agents;
|
|
5138
|
+
const redis2 = options.redis;
|
|
5095
5139
|
const base = new PgModule(pg);
|
|
5096
|
-
const
|
|
5140
|
+
const channels2 = pg.table("_channels", {
|
|
5097
5141
|
id: serial("id").primaryKey(),
|
|
5098
5142
|
tenant_id: text("tenant_id"),
|
|
5099
5143
|
name: text("name").notNull().default(""),
|
|
@@ -5125,16 +5169,16 @@ function messager(options) {
|
|
|
5125
5169
|
});
|
|
5126
5170
|
return {
|
|
5127
5171
|
migrate: async () => {
|
|
5128
|
-
await
|
|
5129
|
-
await
|
|
5172
|
+
await channels2.create();
|
|
5173
|
+
await channels2.createIndex("tenant_id");
|
|
5130
5174
|
await members.create();
|
|
5131
5175
|
await members.createIndex("member_id");
|
|
5132
5176
|
await members.createIndex(["channel_id", "member_id", "member_type"], { unique: true });
|
|
5133
5177
|
await messages2.create();
|
|
5134
5178
|
await messages2.createIndex(["channel_id", "created_at"], { desc: true });
|
|
5135
5179
|
},
|
|
5136
|
-
router: () => buildRouter3({ sql: sql2, channels:
|
|
5137
|
-
wsHandler: () => createWSHandler({ sql: sql2, agents }),
|
|
5180
|
+
router: () => buildRouter3({ sql: sql2, channels: channels2, members, messages: messages2, agents }),
|
|
5181
|
+
wsHandler: () => createWSHandler({ sql: sql2, agents, redis: redis2 }),
|
|
5138
5182
|
async send(channelId, content, opts) {
|
|
5139
5183
|
const msg = await messages2.insert({
|
|
5140
5184
|
channel_id: channelId,
|
|
@@ -7013,7 +7057,7 @@ function logdb(options) {
|
|
|
7013
7057
|
import crypto5 from "node:crypto";
|
|
7014
7058
|
|
|
7015
7059
|
// iii/stream.ts
|
|
7016
|
-
var
|
|
7060
|
+
var channels = /* @__PURE__ */ new Map();
|
|
7017
7061
|
function notify(stream, group, item, event, data) {
|
|
7018
7062
|
const keys = [
|
|
7019
7063
|
`${stream}`,
|
|
@@ -7022,7 +7066,7 @@ function notify(stream, group, item, event, data) {
|
|
|
7022
7066
|
];
|
|
7023
7067
|
const msg = JSON.stringify({ type: "stream", stream_name: stream, group_id: group, item_id: item, event, data });
|
|
7024
7068
|
for (const key of keys) {
|
|
7025
|
-
const subs =
|
|
7069
|
+
const subs = channels.get(key);
|
|
7026
7070
|
if (!subs) continue;
|
|
7027
7071
|
for (const ws of subs) {
|
|
7028
7072
|
try {
|
|
@@ -7347,14 +7391,14 @@ function createStream(opts) {
|
|
|
7347
7391
|
...store,
|
|
7348
7392
|
subscribe(ws, sub) {
|
|
7349
7393
|
const key = sub.item_id ? `${sub.stream_name}:${sub.group_id}:${sub.item_id}` : sub.group_id ? `${sub.stream_name}:${sub.group_id}` : sub.stream_name;
|
|
7350
|
-
if (!
|
|
7351
|
-
|
|
7394
|
+
if (!channels.has(key)) channels.set(key, /* @__PURE__ */ new Set());
|
|
7395
|
+
channels.get(key).add(ws);
|
|
7352
7396
|
if (redisSub && sub.stream_name) {
|
|
7353
7397
|
redisSub.subscribe(`iii:stream:${sub.stream_name}`);
|
|
7354
7398
|
}
|
|
7355
7399
|
},
|
|
7356
7400
|
unsubscribe(ws) {
|
|
7357
|
-
for (const [, subs] of
|
|
7401
|
+
for (const [, subs] of channels) subs.delete(ws);
|
|
7358
7402
|
},
|
|
7359
7403
|
async migrate() {
|
|
7360
7404
|
if (opts?.pg) {
|
|
@@ -7997,6 +8041,7 @@ export {
|
|
|
7997
8041
|
auth,
|
|
7998
8042
|
compress,
|
|
7999
8043
|
cors,
|
|
8044
|
+
createHub,
|
|
8000
8045
|
createOpenAI,
|
|
8001
8046
|
createSSEStream,
|
|
8002
8047
|
createTestServer,
|
package/dist/messager/types.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { AgentModule } from '../agent/types.ts';
|
|
2
2
|
import type { PostgresClient } from '../postgres/types.ts';
|
|
3
|
+
import type { Redis } from '../vendor.ts';
|
|
3
4
|
export interface MessagerOptions {
|
|
4
5
|
pg: PostgresClient;
|
|
5
6
|
agents?: AgentModule;
|
|
6
7
|
webhookTimeout?: number;
|
|
8
|
+
redis?: Redis;
|
|
7
9
|
}
|
|
8
10
|
export interface Channel {
|
|
9
11
|
id: number;
|
package/dist/messager/ws.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { AgentModule } from '../agent/types.ts';
|
|
|
3
3
|
interface WSDeps {
|
|
4
4
|
sql: Sql<{}>;
|
|
5
5
|
agents?: AgentModule;
|
|
6
|
+
redis?: import('../vendor.ts').Redis;
|
|
6
7
|
}
|
|
7
8
|
export declare function broadcastToChannel(channelId: number, data: any): void;
|
|
8
9
|
export declare function createWSHandler(deps: WSDeps): any;
|