Skip to content

Commit

Permalink
Setup anycable
Browse files Browse the repository at this point in the history
  • Loading branch information
arjunkomath committed Jan 28, 2025
1 parent b479d0c commit 9e48099
Show file tree
Hide file tree
Showing 11 changed files with 213 additions and 63 deletions.
41 changes: 23 additions & 18 deletions app/(dashboard)/[tenant]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AppSidebar } from "@/components/app-sidebar";
import { ReportTimezone } from "@/components/core/report-timezone";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { CableProvider } from "@/lib/utils/cable-client";
import { getToken } from "@/lib/utils/cable-server";
import { isDatabaseReady } from "@/lib/utils/useDatabase";
import { getOwner, getUser } from "@/lib/utils/useOwner";
import { redirect } from "next/navigation";
Expand Down Expand Up @@ -29,26 +31,29 @@ export default async function ConsoleLayout(props: {
redirect("/start");
}

const cableToken = await getToken(userId);

return (
<SidebarProvider>
<AppSidebar
userId={userId}
user={{
firstName: user.firstName ?? "",
email: user.email ?? "",
imageUrl: null,
}}
/>
<main className="relative mx-auto w-full flex-grow lg:flex">
<SidebarTrigger className="absolute top-[18px] left-4 z-50" />
<div className="min-w-0 flex-1 xl:flex">
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 lg:min-w-0 lg:flex-1 pb-8">
{children}
<CableProvider token={cableToken}>
<SidebarProvider>
<AppSidebar
user={{
firstName: user.firstName ?? "",
email: user.email ?? "",
imageUrl: null,
}}
/>
<main className="relative mx-auto w-full flex-grow lg:flex">
<SidebarTrigger className="absolute top-[18px] left-4 z-50" />
<div className="min-w-0 flex-1 xl:flex">
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 lg:min-w-0 lg:flex-1 pb-8">
{children}
</div>
</div>
</div>

<ReportTimezone />
</main>
</SidebarProvider>
<ReportTimezone />
</main>
</SidebarProvider>
</CableProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ export async function createTaskList(payload: FormData) {
});

const db = await database();
const newTaskList = await db
.insert(taskList)
db.insert(taskList)
.values({
...data,
projectId: +projectId,
Expand Down
6 changes: 6 additions & 0 deletions app/(dashboard)/[tenant]/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { logtoConfig } from "@/app/logto";
import { notification, user } from "@/drizzle/schema";
import { updateUser } from "@/lib/ops/auth";
import { getStreamFor, getToken } from "@/lib/utils/cable-server";
import { database } from "@/lib/utils/useDatabase";
import { getOwner } from "@/lib/utils/useOwner";
import { signOut } from "@logto/next/server-actions";
Expand Down Expand Up @@ -57,6 +58,11 @@ export async function getUserNotifications() {
return notifications;
}

export async function getNotificationsStream() {
const { userId } = await getOwner();
return getStreamFor("notifications", userId);
}

export async function logout() {
await signOut(logtoConfig);
}
4 changes: 1 addition & 3 deletions components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,9 @@ import {
import type * as React from "react";

export function AppSidebar({
userId,
user,
...props
}: React.ComponentProps<typeof Sidebar> & {
userId: string;
user: {
firstName: string;
imageUrl: string | null;
Expand All @@ -31,7 +29,7 @@ export function AppSidebar({
<WorkspaceSwitcher />
</SidebarHeader>
<SidebarContent>
<NavMain userId={userId} />
<NavMain />
<NavProjects />
</SidebarContent>
<SidebarFooter>
Expand Down
41 changes: 33 additions & 8 deletions components/core/notifications.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,54 @@
"use client";

import { getUserNotifications } from "@/app/(dashboard)/[tenant]/settings/actions";
import {
getNotificationsStream,
getUserNotifications,
} from "@/app/(dashboard)/[tenant]/settings/actions";
import { cn } from "@/lib/utils";
import { useCable } from "@/lib/utils/cable-client";
import type { Channel } from "@anycable/web";
import { Bell, Dot } from "lucide-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
import { SidebarMenuButton, SidebarMenuItem, useSidebar } from "../ui/sidebar";

function Notifications({ tenant, userId }: { tenant: string; userId: string }) {
function Notifications({ tenant }: { tenant: string }) {
const cable = useCable();
const { setOpenMobile } = useSidebar();
const pathname = usePathname();

const isActive = pathname === `/${tenant}/notifications`;

useEffect(() => {
getUserNotifications().then((notifications) => {
setUnreadCount(notifications.filter((x) => !x.read).length);
if (!cable) return;

let channel: Channel | undefined;

getNotificationsStream().then((stream) => {
channel = cable.streamFromSigned(stream);
channel.on("message", (_) => {
getUserNotifications().then((notifications) => {
setUnreadCount(notifications.filter((x) => !x.read).length);
});
});
});
}, []);

return () => {
channel?.disconnect();
};
}, [cable]);

const [unreadCount, setUnreadCount] = useState<number>(0);

return (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<SidebarMenuButton
asChild
onClick={() => {
setOpenMobile(false);
}}
>
<Link href={`/${tenant}/notifications`} className="relative flex">
<Bell className={cn(isActive ? "text-primary" : "")} />
<span className={cn(isActive ? "font-semibold" : "")}>
Expand Down
4 changes: 2 additions & 2 deletions components/nav-main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ type MainNavItem = {
}[];
};

export function NavMain({ userId }: { userId: string }) {
export function NavMain() {
const { setOpenMobile } = useSidebar();
const { tenant, projectId } = useParams();
const pathname = usePathname();
Expand Down Expand Up @@ -165,7 +165,7 @@ export function NavMain({ userId }: { userId: string }) {
return (
<SidebarGroup>
<SidebarMenu>
<Notifications tenant={String(tenant)} userId={userId} />
<Notifications tenant={String(tenant)} />
</SidebarMenu>
<SidebarGroupLabel className="font-bold mt-4">Tools</SidebarGroupLabel>
<SidebarMenu>
Expand Down
58 changes: 28 additions & 30 deletions docker/local/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
services:
# Use the following services to run Logto in a local environment.
# logto_app:
# depends_on:
# logto_postgres:
# condition: service_healthy
# image: svhd/logto:${TAG-latest}
# entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
# ports:
# - 3001:3001
# - 3002:3002
# environment:
# - TRUST_PROXY_HEADER=1
# - DB_URL=postgres://postgres:p0stgr3s@logto_postgres:5432/logto
# # Mandatory for GitPod to map host env to the container, thus GitPod can dynamically configure the public URL of Logto;
# # Or, you can leverage it for local testing.
# - ENDPOINT
# - ADMIN_ENDPOINT
# logto_postgres:
# image: postgres:17-alpine
# user: postgres
# environment:
# POSTGRES_USER: postgres
# POSTGRES_PASSWORD: p0stgr3s
# healthcheck:
# test: ["CMD-SHELL", "pg_isready"]
# interval: 10s
# timeout: 5s
# retries: 5
logto_app:
depends_on:
logto_postgres:
condition: service_healthy
image: svhd/logto:${TAG-latest}
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
ports:
- 3001:3001
- 3002:3002
environment:
- TRUST_PROXY_HEADER=1
- DB_URL=postgres://postgres:p0stgr3s@logto_postgres:5432/logto
# Mandatory for GitPod to map host env to the container, thus GitPod can dynamically configure the public URL of Logto;
# Or, you can leverage it for local testing.
- ENDPOINT
- ADMIN_ENDPOINT
logto_postgres:
image: postgres:17-alpine
user: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: p0stgr3s
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5

# Use the following services to run MinIO (S3 compatible storage) in a local environment.
minio:
image: quay.io/minio/minio
ports:
Expand All @@ -39,4 +37,4 @@ services:
MINIO_ROOT_PASSWORD: ROOTPASSWORD
volumes:
- ./blob-data:/data
command: server /data --console-address ":9001"
command: server /data --console-address ":9001"
37 changes: 37 additions & 0 deletions lib/utils/cable-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import { createCable } from "@anycable/web";
import { createContext, useContext } from "react";

const getAuthenticatedCable = (token: string) =>
createCable(
`${process.env.NEXT_PUBLIC_ANYCABLE_WEBSOCKET_URL!}?jid=${token}`,
{
logLevel: "debug",
},
);

type CableContext = {
cable: ReturnType<typeof getAuthenticatedCable>;
};

const CableContext = createContext<CableContext | null>(null);

export function useCable() {
const context = useContext(CableContext);
if (!context) {
throw new Error("useCable must be used within a CableProvider");
}
return context.cable;
}

export const CableProvider = ({
children,
token,
}: { children: React.ReactNode; token: string }) => {
const cable = getAuthenticatedCable(token);

return (
<CableContext.Provider value={{ cable }}>{children}</CableContext.Provider>
);
};
40 changes: 40 additions & 0 deletions lib/utils/cable-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { broadcaster, identificator } from "@anycable/serverless-js";
import { signer } from "@anycable/serverless-js";

const secret = process.env.ANYCABLE_SECRET;
const jwtTTL = "1h";

const broadcastURL = process.env.ANYCABLE_BROADCAST_URL!;
const broadcastKey = process.env.ANYCABLE_BROADCAST_KEY!;

export type Event = "notifications";

export async function getToken(userId: string) {
if (!secret) {
throw new Error("ANYCABLE_SECRET is not set");
}

const identifier = identificator(secret, jwtTTL);
const token = await identifier.generateToken({ userId });
return token;
}

export async function getStreamFor(room: Event, userId: string) {
if (!secret) {
throw new Error("ANYCABLE_STREAMS_SECRET is not set");
}

const sign = signer(secret);
const signedStreamName = sign(`${room}/${userId}`);

return signedStreamName;
}

export async function broadcastEvent(
room: Event,
userId: string,
message: Record<string, string | number>,
) {
const broadcastTo = broadcaster(broadcastURL, broadcastKey);
await broadcastTo(`${room}/${userId}`, message);
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"generate:migrations": "drizzle-kit generate"
},
"dependencies": {
"@anycable/serverless-js": "^0.2.1",
"@anycable/web": "^1.0.0",
"@aws-sdk/client-s3": "^3.623.0",
"@aws-sdk/signature-v4-crt": "^3.622.0",
"@dnd-kit/core": "^6.1.0",
Expand Down
Loading

0 comments on commit 9e48099

Please sign in to comment.