Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions examples/basic-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ import { cfProvider } from "@mvrx/mail/providers";
import { classify } from "@mvrx/mail/ai-tools";
import { compose } from "@mvrx/mail/compose";
import { processors } from "@mvrx/mail/attachments";
import { publishEvent, hubRouter } from "@mvrx/mail/hub";
import { publishEvent, relayRouter } from "@mvrx/mail/relay";

// Register the UserHub Durable Object (backs real-time SSE events).
export { UserHub } from "@mvrx/mail/hub";
// Register the UserRelay Durable Object (backs real-time SSE events).
export { UserRelay } from "@mvrx/mail/relay";

interface Env {
DB: D1Database;
BLOBS: R2Bucket;
AI: Ai;
EMAIL: SendEmail;
HUB: DurableObjectNamespace;
RELAY: DurableObjectNamespace;
AGENT_MODEL_CLASSIFY: string;
AGENT_MODEL_CHAT: string;
}
Expand Down Expand Up @@ -43,7 +43,7 @@ export default {
const results = await evaluateRules(email, rules, cfTransport(env.EMAIL));
for (const r of results) {
if (!r.matched) continue;
await publishEvent(env.HUB, userId, {
await publishEvent(env.RELAY, userId, {
type: "rule_fired",
payload: {
ruleId: r.ruleId,
Expand All @@ -55,7 +55,7 @@ export default {
}

// Push a real-time "new message" event to any connected SSE clients.
await publishEvent(env.HUB, userId, {
await publishEvent(env.RELAY, userId, {
type: "new_message",
payload: {
messageId: email.messageId,
Expand Down Expand Up @@ -90,13 +90,13 @@ export default {
);
},

// Mount the real-time SSE endpoint: clients connect with `new EventSource("/hub")`.
// Mount the real-time SSE endpoint: clients connect with `new EventSource("/relay")`.
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
if (url.pathname === "/hub") {
if (url.pathname === "/relay") {
// Derive the userId from your auth in production; single-tenant demo below.
const userId = url.searchParams.get("user") ?? "demo";
return hubRouter(req, env.HUB, userId);
return relayRouter(req, env.RELAY, userId);
}
return new Response("AECS mail Worker — receive, store, rules, events, classify, reply");
},
Expand Down
6 changes: 3 additions & 3 deletions examples/basic-worker/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@
"durable_objects": {
"bindings": [
{
"name": "HUB",
"class_name": "UserHub"
"name": "RELAY",
"class_name": "UserRelay"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["UserHub"]
"new_sqlite_classes": ["UserRelay"]
}
],

Expand Down
6 changes: 3 additions & 3 deletions packages/mail/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@
"import": "./dist/attachments/index.js",
"types": "./dist/attachments/index.d.ts"
},
"./hub": {
"import": "./dist/hub/index.js",
"types": "./dist/hub/index.d.ts"
"./relay": {
"import": "./dist/relay/index.js",
"types": "./dist/relay/index.d.ts"
}
},
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/mail/src/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export interface MailEvent {

/**
* Abstracts real-time fan-out to connected clients.
* CF implementation: UserHub Durable Object via `hubBus(env.HUB)` in `@mvrx/mail/hub`.
* CF implementation: UserRelay Durable Object via `relayBus(env.RELAY)` in `@mvrx/mail/relay`.
* For non-CF deployments: implement with WebSockets, SSE, or webhooks.
*/
export interface NotificationBus {
Expand Down
34 changes: 17 additions & 17 deletions packages/mail/src/hub/index.ts → packages/mail/src/relay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import type { MailEvent as LooseMailEvent, NotificationBus } from "../adapters.j
/**
* Real-time events for @mvrx/mail (AECS-SDK-1 §16).
*
* `UserHub` is a Durable Object — one instance per user, keyed by an opaque
* `UserRelay` is a Durable Object — one instance per user, keyed by an opaque
* `userId` — that holds open Server-Sent Events (SSE) connections and fans out
* `MailEvent`s to them. `publishEvent`/`hubRouter`/`hubBus` are the SDK helpers
* `MailEvent`s to them. `publishEvent`/`relayRouter`/`relayBus` are the SDK helpers
* that route through the DO.
*
* Delivery is fire-and-forget, at-most-once, with NO replay (§16.5): if no
Expand Down Expand Up @@ -47,7 +47,7 @@ export type MailEvent =
};

// The typed union above must stay assignable to the looser adapters.ts MailEvent
// ({ type: MailEventType; payload: Record<string, unknown> }) so UserHub can
// ({ type: MailEventType; payload: Record<string, unknown> }) so UserRelay can
// satisfy NotificationBus. This assertion fails the build if they ever diverge.
const _assertAssignable: LooseMailEvent = null as unknown as MailEvent;
void _assertAssignable;
Expand All @@ -64,14 +64,14 @@ export function toSseFrame(event: MailEvent): Uint8Array {
const KEEPALIVE_MS = 25_000;
const KEEPALIVE_FRAME = encoder.encode(": keep-alive\n\n");

// ── UserHub Durable Object ───────────────────────────────────────────────────
// ── UserRelay Durable Object ───────────────────────────────────────────────────

/**
* One instance per user. Holds the set of open SSE stream controllers and fans
* `MailEvent`s out to them. Register the class in wrangler with a DO binding +
* migration (see the example worker), then `export { UserHub } from "@mvrx/mail/hub"`.
* migration (see the example worker), then `export { UserRelay } from "@mvrx/mail/relay"`.
*/
export class UserHub extends DurableObject {
export class UserRelay extends DurableObject {
private controllers = new Set<ReadableStreamDefaultController<Uint8Array>>();
private keepAlive: ReturnType<typeof setInterval> | null = null;

Expand Down Expand Up @@ -145,8 +145,8 @@ export class UserHub extends DurableObject {

// ── SDK helpers ──────────────────────────────────────────────────────────────

function stubFor(hub: DurableObjectNamespace, userId: string): DurableObjectStub {
return hub.get(hub.idFromName(userId));
function stubFor(relay: DurableObjectNamespace, userId: string): DurableObjectStub {
return relay.get(relay.idFromName(userId));
}

/**
Expand All @@ -155,11 +155,11 @@ function stubFor(hub: DurableObjectNamespace, userId: string): DurableObjectStub
* Worker handler.
*/
export async function publishEvent(
hub: DurableObjectNamespace,
relay: DurableObjectNamespace,
userId: string,
event: MailEvent
): Promise<void> {
await stubFor(hub, userId).fetch("https://user-hub/publish", {
await stubFor(relay, userId).fetch("https://user-relay/publish", {
method: "POST",
body: JSON.stringify(event),
});
Expand All @@ -168,22 +168,22 @@ export async function publishEvent(
/**
* Mount as an SSE endpoint. Returns a `text/event-stream` Response that stays
* open and streams this user's `MailEvent`s. Wire it into your fetch handler:
* `if (url.pathname === "/hub") return hubRouter(req, env.HUB, getUserId(req))`.
* `if (url.pathname === "/relay") return relayRouter(req, env.RELAY, getUserId(req))`.
*/
export async function hubRouter(
export async function relayRouter(
_req: Request,
hub: DurableObjectNamespace,
relay: DurableObjectNamespace,
userId: string
): Promise<Response> {
return stubFor(hub, userId).fetch("https://user-hub/connect");
return stubFor(relay, userId).fetch("https://user-relay/connect");
}

/**
* Adapts a UserHub DO namespace to the `NotificationBus` interface (adapters.ts),
* Adapts a UserRelay DO namespace to the `NotificationBus` interface (adapters.ts),
* so it can be passed anywhere a generic bus is expected.
*/
export function hubBus(hub: DurableObjectNamespace): NotificationBus {
export function relayBus(relay: DurableObjectNamespace): NotificationBus {
return {
publish: (userId, event) => publishEvent(hub, userId, event as MailEvent),
publish: (userId, event) => publishEvent(relay, userId, event as MailEvent),
};
}
2 changes: 1 addition & 1 deletion packages/mail/test/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ declare module "cloudflare:test" {
DB: D1Database;
BLOBS: R2Bucket;
CACHE: KVNamespace;
HUB: DurableObjectNamespace;
RELAY: DurableObjectNamespace;
}
}
30 changes: 15 additions & 15 deletions packages/mail/test/hub.test.ts → packages/mail/test/relay.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { env, runInDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";
import { UserHub, publishEvent, hubRouter, hubBus, toSseFrame, type MailEvent } from "../src/hub/index.js";
import { UserRelay, publishEvent, relayRouter, relayBus, toSseFrame, type MailEvent } from "../src/relay/index.js";

const newMessage: MailEvent = {
type: "new_message",
Expand All @@ -25,22 +25,22 @@ describe("toSseFrame", () => {
});
});

describe("UserHub", () => {
describe("UserRelay", () => {
it("drops events when no client is connected (fire-and-forget)", async () => {
await publishEvent(env.HUB, "nobody@example.com", newMessage); // must not throw
const stub = env.HUB.get(env.HUB.idFromName("nobody@example.com"));
const count = await runInDurableObject(stub, (instance: UserHub) => instance.connectionCount());
await publishEvent(env.RELAY, "nobody@example.com", newMessage); // must not throw
const stub = env.RELAY.get(env.RELAY.idFromName("nobody@example.com"));
const count = await runInDurableObject(stub, (instance: UserRelay) => instance.connectionCount());
expect(count).toBe(0);
});

it("delivers a published event to a connected SSE client", async () => {
const res = await hubRouter(new Request("https://worker/hub"), env.HUB, "u1@example.com");
const res = await relayRouter(new Request("https://worker/relay"), env.RELAY, "u1@example.com");
expect(res.headers.get("content-type")).toContain("text/event-stream");
expect(res.body).not.toBeNull();

const reader = res.body!.getReader();

await publishEvent(env.HUB, "u1@example.com", newMessage);
await publishEvent(env.RELAY, "u1@example.com", newMessage);

const { value } = await reader.read();
const frame = decode(value!);
Expand All @@ -51,26 +51,26 @@ describe("UserHub", () => {
});

it("routes different userIds to different DO instances", async () => {
const resA = await hubRouter(new Request("https://worker/hub"), env.HUB, "a@example.com");
const resA = await relayRouter(new Request("https://worker/relay"), env.RELAY, "a@example.com");
const readerA = resA.body!.getReader();

// Publish only to user b — user a's stream should NOT receive it.
await publishEvent(env.HUB, "b@example.com", newMessage);
await publishEvent(env.RELAY, "b@example.com", newMessage);

const stubA = env.HUB.get(env.HUB.idFromName("a@example.com"));
const stubB = env.HUB.get(env.HUB.idFromName("b@example.com"));
const countA = await runInDurableObject(stubA, (i: UserHub) => i.connectionCount());
const countB = await runInDurableObject(stubB, (i: UserHub) => i.connectionCount());
const stubA = env.RELAY.get(env.RELAY.idFromName("a@example.com"));
const stubB = env.RELAY.get(env.RELAY.idFromName("b@example.com"));
const countA = await runInDurableObject(stubA, (i: UserRelay) => i.connectionCount());
const countB = await runInDurableObject(stubB, (i: UserRelay) => i.connectionCount());
expect(countA).toBe(1); // a is connected
expect(countB).toBe(0); // b never connected

await readerA.cancel();
});
});

describe("hubBus", () => {
describe("relayBus", () => {
it("adapts the namespace to a NotificationBus", async () => {
const bus = hubBus(env.HUB);
const bus = relayBus(env.RELAY);
await bus.publish("c@example.com", newMessage); // must not throw
expect(typeof bus.publish).toBe("function");
});
Expand Down
6 changes: 3 additions & 3 deletions packages/mail/test/worker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Test-only Worker entry: the vitest-pool-workers `main` so the UserHub Durable
// Object class is registered and reachable through the HUB binding in tests.
export { UserHub } from "../src/hub/index.js";
// Test-only Worker entry: the vitest-pool-workers `main` so the UserRelay Durable
// Object class is registered and reachable through the RELAY binding in tests.
export { UserRelay } from "../src/relay/index.js";

export default {
async fetch(): Promise<Response> {
Expand Down
4 changes: 2 additions & 2 deletions packages/mail/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [
cloudflareTest({
// Worker entry that registers the UserHub Durable Object so the HUB
// Worker entry that registers the UserRelay Durable Object so the RELAY
// binding resolves to it in tests.
main: "./test/worker.ts",
miniflare: {
Expand All @@ -15,7 +15,7 @@ export default defineConfig({
d1Databases: ["DB"],
r2Buckets: ["BLOBS"],
kvNamespaces: ["CACHE"],
durableObjects: { HUB: "UserHub" },
durableObjects: { RELAY: "UserRelay" },
},
}),
],
Expand Down
Loading