diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index f09cc19ba..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,78 +0,0 @@ -# VitNode AI Coding Agent Guidelines (Extended) - -The repository is a monorepo for the VitNode framework, which includes a backend API, frontend documentation site, and shared packages. The codebase uses modern web technologies and follows specific conventions for development based on Next.js 15 and Hono.js 4. - -- Do not nest ternary operators, - -## Architecture & Key Patterns - -- **Monorepo Structure:** - - `apps/` contains main apps (`api` for backend, `docs` for docs site) - - `packages/` holds shared code, core framework, ESLint and Prettier configs, and CLI tools - - `plugins/` for extendable features -- **Frontend:** - - Next.js 15, App Router, Server Components - - Avoid using `next/navigation` directly, use `vitnode/lib/navigation` - - Forms: Use `react-hook-form@7`, server actions for mutations - - UI: Shadcn UI, Tailwind CSS 4, dark/light mode with system detection - - i18n: Use `next-intl`, `t('key')` for translations, `getTranslation` (server), `useTranslation` (client) - - Accessibility: WCAG 2.1 AA, semantic HTML, ARIA, keyboard/screen reader support -- **Backend:** - - Hono.js 4, OpenAPI via `@hono/zod-openapi`, Zod 4 for validation - - Database: PostgreSQL via Drizzle ORM, access via `c.get('database')` - - API: RESTful, versioned, rate-limited, secure session management - - Error handling: Use Hono's error middleware, log via `c.get('log')` - - Plugins: Register via `VitNodeAPI` config, routes auto-mounted by pluginId -- **Docs:** - - Written in `.mdx` using Fumadocs, main entry: `apps/docs/content/docs/dev/index.mdx` - - Use `// [!code ++]` to highlight code, `// [!code --]` to hide - - No h1 tags, no emoji in headings - -## Developer Workflow - -- **Package Manager:** Use `pnpm` for all installs/scripts -- **Scripts:** - - `pnpm dev` (dev server), `pnpm build`, `pnpm lint`, `pnpm db:migrate`, `pnpm docker:dev` -- **CLI:** - - Create apps/plugins via `pnpm create vitnode-app@canary` (see `packages/create-vitnode-app`) - - CLI prompts for package manager, app mode, ESLint, Prettier, Docker, install (see `questions.ts`) -- **Linting/Formatting:** - - Use configs from `packages/config/` - - File names: snake_case, ESModule only - - TypeScript 5 strict mode -- **Testing:** - - Use Vitest (see `vitest.config.ts`) -- **Config:** - - Centralized in `vitnode.config.ts` and `api/config.ts` - - Extend via plugins in config arrays - -## Integration & Conventions - -- **External:** - - Next.js, Hono.js, Drizzle ORM, Zod, react-hook-form, Shadcn UI, Tailwind, next-intl -- **Internal:** - - Navigation, config, API, middleware, plugin system -- **Security:** - - XSS protection, content security policy, secure cookies - -## Examples - -- See `apps/api/src/index.ts` for backend API setup -- See `packages/vitnode/src/api/config.ts` for API registration and middleware -- See `packages/vitnode/src/lib/navigation.ts` for navigation API -- See `apps/docs/next.config.ts` for docs site config - ---- - -For unclear or missing patterns, ask for clarification or request more examples from maintainers. - -## New Code - -If you add new code or change existing code, always verify that -everything still works by running _each_ of the following checks: - -1. `npm run lint` to run the linter. -2. `npm run lint:fix` to fix any linting issues. -3. `npm run test` to run the tests. - -Complete the task only after all checks pass. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..23eaf45c4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# VitNode AI Coding Agent Guidelines (Extended) + +The repository is a monorepo for the VitNode framework, which includes a backend API, frontend documentation site, and shared packages. + +## Main Rules + +- Use React 19.2, Next.js 16, TypeScript 5.9, Hono.js 4, +- Use pnpm as the runtime environment, +- Use Tailwind CSS 4 for styling, + +## API + +- Please use as much as possible server-side API calls (server components, server actions), +- For client API calls, use `tanstack/react-query` hooks, + +## TypeScript + +- Avoid using `any` type, +- Do not nest ternary operators, +- Avoid creating `index.ts` files for exports, instead use explicit file names, +- Use only `const` for create functions, but `function` for pages and generic functions, +- Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator, + +## React + +- Don't use `React.FC`, instead explicitly type props +- Don't use `useMemo` and `useCallback`. Project has React Compiler + +## Animations + +- Use `motion/react` for animations and gestures, +- Use `translate` instead of `top`/`left` for moving elements, as it is more performant diff --git a/apps/api/package.json b/apps/api/package.json index c19096f94..ac88545ec 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,9 +14,11 @@ "lint:fix": "eslint . --fix" }, "dependencies": { + "@hono/node-ws": "^1.2.0", "@hono/zod-openapi": "^1.1.4", "@hono/zod-validator": "^0.7.4", "@vitnode/core": "workspace:*", + "@vitnode/blog": "workspace:*", "drizzle-kit": "^0.31.7", "drizzle-orm": "^0.44.7", "hono": "^4.10.6", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 62084e46c..2e0dfd251 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,6 +1,7 @@ import { serve } from "@hono/node-server"; import { OpenAPIHono } from "@hono/zod-openapi"; import { VitNodeAPI } from "@vitnode/core/api/config"; +import { createNodeWebSocket } from "@hono/node-ws"; import { vitNodeApiConfig } from "./vitnode.api.config.js"; @@ -11,7 +12,28 @@ VitNodeAPI({ vitNodeApiConfig, }); -serve( +const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); + +const wsApp = app.get( + "/ws", + upgradeWebSocket(c => ({ + onOpen(event, ws) { + const user = c.get("user"); + console.log("Connection opened", event, ws, user); + }, + onMessage(event, ws) { + console.log(`Message from client`, event.data); + ws.send("Hello from server!"); + }, + onClose: () => { + console.log("Connection closed"); + }, + })), +); + +export type WebSocketApp = typeof wsApp; + +const server = serve( { fetch: app.fetch, port: 8080, @@ -25,3 +47,4 @@ serve( ); }, ); +injectWebSocket(server); diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index 29274f43b..e47cc6c24 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -2,6 +2,7 @@ import { buildApiConfig } from "@vitnode/core/vitnode.config"; import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; import { config } from "dotenv"; import { drizzle } from "drizzle-orm/postgres-js"; +import { blogApiPlugin } from "@vitnode/blog/config.api"; config({ quiet: true, @@ -11,7 +12,7 @@ export const POSTGRES_URL = process.env.POSTGRES_URL ?? "postgresql://root:root@localhost:5432/vitnode"; export const vitNodeApiConfig = buildApiConfig({ - plugins: [], + plugins: [blogApiPlugin()], pathToMessages: async path => await import(`./locales/${path}`), dbProvider: drizzle({ connection: POSTGRES_URL, diff --git a/apps/docs/content/docs/dev/index.mdx b/apps/docs/content/docs/dev/index.mdx index f04aff5ce..c157a3541 100644 --- a/apps/docs/content/docs/dev/index.mdx +++ b/apps/docs/content/docs/dev/index.mdx @@ -10,7 +10,7 @@ icon: Power ## Get started -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; @@ -31,3 +31,11 @@ npx create-vitnode-app@canary ## Why VitNode? something here + +## Types of Applications + +In VitNode, you can create different types of applications to suit your needs: + +- **Single App**: A standalone application that includes both frontend and backend components in `Next.js` with `Hono.js` routes, +- **Monorepo App**: A monorepo structure that allows you to manage multiple packages within a single repository, including frontend and backend components, +- **Only API**: An application focused solely on backend API development without any frontend components using `Hono.js`. diff --git a/apps/docs/content/docs/dev/meta.json b/apps/docs/content/docs/dev/meta.json index 86c671ceb..69c79a5ff 100644 --- a/apps/docs/content/docs/dev/meta.json +++ b/apps/docs/content/docs/dev/meta.json @@ -19,6 +19,7 @@ "email", "sso", "cron", + "websocket", "---Frontend---", "layouts-and-pages", "admin-page", diff --git a/apps/docs/content/docs/dev/sso/custom-adapter.mdx b/apps/docs/content/docs/dev/sso/custom-adapter.mdx index 45a8feeb3..35b055b3b 100644 --- a/apps/docs/content/docs/dev/sso/custom-adapter.mdx +++ b/apps/docs/content/docs/dev/sso/custom-adapter.mdx @@ -16,15 +16,15 @@ import { Callout } from "fumadocs-ui/components/callout"; Let's start with the basics. Create a new file for your SSO provider: ```ts title="src/utils/sso/discord_api.ts" -import { SSOApiPlugin, getRedirectUri } from "@vitnode/core/api/models/sso"; +import { SSOApiAdapter, getRedirectUri } from "@vitnode/core/api/models/sso"; -export const DiscordSSOApiPlugin = ({ +export const DiscordSSOApiAdapter = ({ clientId, clientSecret, }: { clientId: string; clientSecret: string; -}): SSOApiPlugin => { +}): SSOApiAdapter => { const id = "discord"; const redirectUri = getRedirectUri(id); @@ -42,15 +42,15 @@ This is like creating a blueprint for your SSO provider. The `id` will be used i Now let's add the magic that sends users to Discord for login: ```ts title="src/utils/sso/discord_api.ts" -import { SSOApiPlugin, getRedirectUri } from "@vitnode/core/api/models/sso"; +import { SSOApiAdapter, getRedirectUri } from "@vitnode/core/api/models/sso"; -export const DiscordSSOApiPlugin = ({ +export const DiscordSSOApiAdapter = ({ clientId, clientSecret, }: { clientId: string; clientSecret: string; -}): SSOApiPlugin => { +}): SSOApiAdapter => { const id = "discord"; const redirectUri = getRedirectUri(id); @@ -93,7 +93,7 @@ export const DiscordSSOApiPlugin = ({ After the user approves access, Discord sends us a code. Let's exchange it for an access token: ```ts title="src/utils/sso/discord_api.ts" -import { SSOApiPlugin, getRedirectUri } from "@vitnode/core/api/models/sso"; +import { SSOApiAdapter, getRedirectUri } from "@vitnode/core/api/models/sso"; import { HTTPException } from "hono/http-exception"; import { ContentfulStatusCode } from "hono/utils/http-status"; import { z } from "zod"; @@ -103,13 +103,13 @@ const tokenSchema = z.object({ token_type: z.string(), }); -export const DiscordSSOApiPlugin = ({ +export const DiscordSSOApiAdapter = ({ clientId, clientSecret, }: { clientId: string; clientSecret: string; -}): SSOApiPlugin => { +}): SSOApiAdapter => { const id = "discord"; const redirectUri = getRedirectUri(id); @@ -204,7 +204,7 @@ export const DiscordSSOApiPlugin = ({ Finally, let's get the user's profile data using our shiny new access token: ```ts title="src/utils/sso/discord_api.ts" -import { SSOApiPlugin, getRedirectUri } from "@vitnode/core/api/models/sso"; +import { SSOApiAdapter, getRedirectUri } from "@vitnode/core/api/models/sso"; import { HTTPException } from "hono/http-exception"; import { ContentfulStatusCode } from "hono/utils/http-status"; import { z } from "zod"; @@ -215,13 +215,13 @@ const userSchema = z.object({ username: z.string(), }); -export const DiscordSSOApiPlugin = ({ +export const DiscordSSOApiAdapter = ({ clientId, clientSecret, }: { clientId: string; clientSecret: string; -}): SSOApiPlugin => { +}): SSOApiAdapter => { const id = "discord"; const redirectUri = getRedirectUri(id); @@ -324,7 +324,7 @@ Last step! Let's plug your new SSO provider into your app: import { OpenAPIHono } from "@hono/zod-openapi"; import { handle } from "hono/vercel"; import { VitNodeAPI } from "@vitnode/core/api/config"; -import { DiscordSSOApiPlugin } from "@/utils/sso/discord_api"; +import { DiscordSSOApiAdapter } from "@/utils/sso/discord_api"; const app = new OpenAPIHono().basePath("/api"); VitNodeAPI({ @@ -334,7 +334,7 @@ VitNodeAPI({ // [!code ++] ssoAdapters: [ // [!code ++] - DiscordSSOApiPlugin({ + DiscordSSOApiAdapter({ // [!code ++] clientId: process.env.DISCORD_CLIENT_ID, // [!code ++] diff --git a/apps/docs/content/docs/dev/sso/discord.mdx b/apps/docs/content/docs/dev/sso/discord.mdx index 375f29015..30c717a2e 100644 --- a/apps/docs/content/docs/dev/sso/discord.mdx +++ b/apps/docs/content/docs/dev/sso/discord.mdx @@ -58,7 +58,7 @@ Add the Discord SSO plugin to your API routes. ```ts title="src/app/api/[...route]/route.ts" // [!code ++] -import { DiscordSSOApiPlugin } from '@vitnode/core/api/plugins/sso/discord'; +import { DiscordSSOApiAdapter } from "@vitnode/core/api/plugins/sso/discord"; VitNodeAPI({ app, @@ -68,7 +68,7 @@ VitNodeAPI({ // [!code ++] ssoAdapters: [ // [!code ++] - new DiscordSSOApiPlugin({ + new DiscordSSOApiAdapter({ // [!code ++] clientId: process.env.DISCORD_CLIENT_ID, // [!code ++] diff --git a/apps/docs/content/docs/dev/sso/facebook.mdx b/apps/docs/content/docs/dev/sso/facebook.mdx index c0c7539a8..bd3a958a1 100644 --- a/apps/docs/content/docs/dev/sso/facebook.mdx +++ b/apps/docs/content/docs/dev/sso/facebook.mdx @@ -52,7 +52,7 @@ Add the Facebook SSO plugin to your API routes. ```ts title="src/app/api/[...route]/route.ts" // [!code ++] -import { FacebookSSOApiPlugin } from '@vitnode/core/api/plugins/sso/facebook'; +import { FacebookSSOApiAdapter } from '@vitnode/core/api/plugins/sso/facebook'; VitNodeAPI({ app, @@ -62,7 +62,7 @@ VitNodeAPI({ // [!code ++] ssoAdapters: [ // [!code ++] - new FacebookSSOApiPlugin({ + new FacebookSSOApiAdapter({ // [!code ++] clientId: process.env.FACEBOOK_CLIENT_ID, // [!code ++] diff --git a/apps/docs/content/docs/dev/sso/google.mdx b/apps/docs/content/docs/dev/sso/google.mdx index c938b6dc2..b4928d2cf 100644 --- a/apps/docs/content/docs/dev/sso/google.mdx +++ b/apps/docs/content/docs/dev/sso/google.mdx @@ -113,7 +113,7 @@ Add the Discord SSO plugin to your API routes. ```ts title="src/app/api/[...route]/route.ts" // [!code ++] -import { GoogleSSOApiPlugin } from '@vitnode/core/api/plugins/sso/google'; +import { GoogleSSOApiAdapter } from "@vitnode/core/api/plugins/sso/google"; VitNodeAPI({ app, @@ -123,7 +123,7 @@ VitNodeAPI({ // [!code ++] ssoAdapters: [ // [!code ++] - new GoogleSSOApiPlugin({ + new GoogleSSOApiAdapter({ // [!code ++] clientId: process.env.GOOGLE_CLIENT_ID, // [!code ++] diff --git a/apps/docs/content/docs/dev/websocket/index.mdx b/apps/docs/content/docs/dev/websocket/index.mdx new file mode 100644 index 000000000..9b55908d4 --- /dev/null +++ b/apps/docs/content/docs/dev/websocket/index.mdx @@ -0,0 +1,6 @@ +--- +title: WebSocket +description: xx +--- + +test diff --git a/apps/docs/content/docs/dev/websocket/meta.json b/apps/docs/content/docs/dev/websocket/meta.json new file mode 100644 index 000000000..c966c65a3 --- /dev/null +++ b/apps/docs/content/docs/dev/websocket/meta.json @@ -0,0 +1,4 @@ +{ + "title": "WebSocket", + "pages": ["..."] +} diff --git a/apps/docs/src/content/docs/guides/sso/discord.mdx b/apps/docs/src/content/docs/guides/sso/discord.mdx index 375f29015..30c717a2e 100644 --- a/apps/docs/src/content/docs/guides/sso/discord.mdx +++ b/apps/docs/src/content/docs/guides/sso/discord.mdx @@ -58,7 +58,7 @@ Add the Discord SSO plugin to your API routes. ```ts title="src/app/api/[...route]/route.ts" // [!code ++] -import { DiscordSSOApiPlugin } from '@vitnode/core/api/plugins/sso/discord'; +import { DiscordSSOApiAdapter } from "@vitnode/core/api/plugins/sso/discord"; VitNodeAPI({ app, @@ -68,7 +68,7 @@ VitNodeAPI({ // [!code ++] ssoAdapters: [ // [!code ++] - new DiscordSSOApiPlugin({ + new DiscordSSOApiAdapter({ // [!code ++] clientId: process.env.DISCORD_CLIENT_ID, // [!code ++] diff --git a/apps/docs/src/content/docs/guides/sso/facebook.mdx b/apps/docs/src/content/docs/guides/sso/facebook.mdx index c0c7539a8..bd3a958a1 100644 --- a/apps/docs/src/content/docs/guides/sso/facebook.mdx +++ b/apps/docs/src/content/docs/guides/sso/facebook.mdx @@ -52,7 +52,7 @@ Add the Facebook SSO plugin to your API routes. ```ts title="src/app/api/[...route]/route.ts" // [!code ++] -import { FacebookSSOApiPlugin } from '@vitnode/core/api/plugins/sso/facebook'; +import { FacebookSSOApiAdapter } from '@vitnode/core/api/plugins/sso/facebook'; VitNodeAPI({ app, @@ -62,7 +62,7 @@ VitNodeAPI({ // [!code ++] ssoAdapters: [ // [!code ++] - new FacebookSSOApiPlugin({ + new FacebookSSOApiAdapter({ // [!code ++] clientId: process.env.FACEBOOK_CLIENT_ID, // [!code ++] diff --git a/apps/docs/src/content/docs/guides/sso/google.mdx b/apps/docs/src/content/docs/guides/sso/google.mdx index c938b6dc2..b4928d2cf 100644 --- a/apps/docs/src/content/docs/guides/sso/google.mdx +++ b/apps/docs/src/content/docs/guides/sso/google.mdx @@ -113,7 +113,7 @@ Add the Discord SSO plugin to your API routes. ```ts title="src/app/api/[...route]/route.ts" // [!code ++] -import { GoogleSSOApiPlugin } from '@vitnode/core/api/plugins/sso/google'; +import { GoogleSSOApiAdapter } from "@vitnode/core/api/plugins/sso/google"; VitNodeAPI({ app, @@ -123,7 +123,7 @@ VitNodeAPI({ // [!code ++] ssoAdapters: [ // [!code ++] - new GoogleSSOApiPlugin({ + new GoogleSSOApiAdapter({ // [!code ++] clientId: process.env.GOOGLE_CLIENT_ID, // [!code ++] diff --git a/apps/docs/src/vitnode.api.config.ts b/apps/docs/src/vitnode.api.config.ts index 49cb91b54..5c82d252f 100644 --- a/apps/docs/src/vitnode.api.config.ts +++ b/apps/docs/src/vitnode.api.config.ts @@ -1,8 +1,8 @@ import { blogApiPlugin } from "@vitnode/blog/config.api"; -import { DiscordSSOApiPlugin } from "@vitnode/core/api/adapters/sso/discord"; +import { DiscordSSOApiAdapter } from "@vitnode/core/api/adapters/sso/discord"; // import { ResendEmailAdapter } from "@vitnode/resend"; -import { FacebookSSOApiPlugin } from "@vitnode/core/api/adapters/sso/facebook"; -import { GoogleSSOApiPlugin } from "@vitnode/core/api/adapters/sso/google"; +import { FacebookSSOApiAdapter } from "@vitnode/core/api/adapters/sso/facebook"; +import { GoogleSSOApiAdapter } from "@vitnode/core/api/adapters/sso/google"; import { buildApiConfig } from "@vitnode/core/vitnode.config"; import { NodeCronAdapter } from "@vitnode/node-cron"; import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; @@ -50,15 +50,15 @@ export const vitNodeApiConfig = buildApiConfig({ }, authorization: { ssoAdapters: [ - DiscordSSOApiPlugin({ + DiscordSSOApiAdapter({ clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET, }), - GoogleSSOApiPlugin({ + GoogleSSOApiAdapter({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }), - FacebookSSOApiPlugin({ + FacebookSSOApiAdapter({ clientId: process.env.FACEBOOK_CLIENT_ID, clientSecret: process.env.FACEBOOK_CLIENT_SECRET, }), diff --git a/packages/vitnode/src/api/adapters/sso/discord.ts b/packages/vitnode/src/api/adapters/sso/discord.ts index c9a650ab3..3a075b2cd 100644 --- a/packages/vitnode/src/api/adapters/sso/discord.ts +++ b/packages/vitnode/src/api/adapters/sso/discord.ts @@ -3,17 +3,17 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; -import type { SSOApiPlugin } from "@/api/models/sso"; +import type { SSOApiAdapter } from "@/api/models/sso"; import { getRedirectUri } from "@/api/models/sso"; -export const DiscordSSOApiPlugin = ({ +export const DiscordSSOApiAdapter = ({ clientId = "", clientSecret = "", }: { clientId: string | undefined; clientSecret: string | undefined; -}): SSOApiPlugin => { +}): SSOApiAdapter => { const id = "discord"; const redirectUri = getRedirectUri(id); const userSchema = z.object({ diff --git a/packages/vitnode/src/api/adapters/sso/facebook.ts b/packages/vitnode/src/api/adapters/sso/facebook.ts index cb5577426..6f1bcc0d1 100644 --- a/packages/vitnode/src/api/adapters/sso/facebook.ts +++ b/packages/vitnode/src/api/adapters/sso/facebook.ts @@ -3,17 +3,17 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; -import type { SSOApiPlugin } from "@/api/models/sso"; +import type { SSOApiAdapter } from "@/api/models/sso"; import { getRedirectUri } from "@/api/models/sso"; -export const FacebookSSOApiPlugin = ({ +export const FacebookSSOApiAdapter = ({ clientId, clientSecret, }: { clientId: string | undefined; clientSecret: string | undefined; -}): SSOApiPlugin => { +}): SSOApiAdapter => { const id = "facebook"; const redirectUri = getRedirectUri(id); const tokenSchema = z.object({ diff --git a/packages/vitnode/src/api/adapters/sso/google.ts b/packages/vitnode/src/api/adapters/sso/google.ts index 366577f10..0c2b192fe 100644 --- a/packages/vitnode/src/api/adapters/sso/google.ts +++ b/packages/vitnode/src/api/adapters/sso/google.ts @@ -3,17 +3,17 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; -import type { SSOApiPlugin } from "@/api/models/sso"; +import type { SSOApiAdapter } from "@/api/models/sso"; import { getRedirectUri } from "@/api/models/sso"; -export const GoogleSSOApiPlugin = ({ +export const GoogleSSOApiAdapter = ({ clientId, clientSecret, }: { clientId: string | undefined; clientSecret: string | undefined; -}): SSOApiPlugin => { +}): SSOApiAdapter => { const id = "google"; const redirectUri = getRedirectUri(id); const tokenSchema = z.object({ diff --git a/packages/vitnode/src/api/lib/websocket.ts b/packages/vitnode/src/api/lib/websocket.ts new file mode 100644 index 000000000..d52b775dd --- /dev/null +++ b/packages/vitnode/src/api/lib/websocket.ts @@ -0,0 +1,25 @@ +import type { Context } from "hono"; +import { upgradeWebSocket } from "hono/cloudflare-workers"; +import type { WSContext } from "hono/ws"; + +export type WebsocketAdapter = (c: Context) => { + onOpen: () => void; +}; + +const test = upgradeWebSocket(c => { + const wsManager = c.get("core").websocketManager; + + return { + onOpen(evt: Event, ws: WSContext) { + const user = c.get("user"); + console.log("Connection opened", user); + }, + onMessage(event, ws) { + console.log(`Message from client: ${event.data}`); + ws.send("Hello from server!"); + }, + onClose: () => { + console.log("Connection closed"); + }, + }; +}); diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 981300229..ecd1d2c74 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -10,12 +10,17 @@ import { SessionAdminModel } from "@/api/models/session-admin"; import { CONFIG } from "@/lib/config"; import type { BuildCronReturn } from "../lib/cron"; -import type { SSOApiPlugin } from "../models/sso"; +import type { SSOApiAdapter } from "../models/sso"; import { loggerMiddleware, type LoggerMiddlewareType, } from "../lib/logger-middleware"; +import { WebsocketModel } from "../models/websocket/model"; +import { + WebsocketManager, + type WebsocketManagerConfig, +} from "../models/websocket/manager"; declare module "hono" { // eslint-disable-next-line @typescript-eslint/no-empty-object-type @@ -50,8 +55,9 @@ export interface EnvVariablesVitNode { cookieSecure: boolean; deviceCookieExpires: number; deviceCookieName: string; - ssoAdapters: SSOApiPlugin[]; + ssoAdapters: SSOApiAdapter[]; }; + websocketManager: WebsocketManagerConfig; captcha?: Pick["captcha"]; cron: (BuildCronReturn & { module: string; pluginId: string })[]; cronSecret?: string; @@ -84,6 +90,9 @@ export interface EnvVariablesVitNode { newsletter: boolean; roleId: number; }; + websocket: { + test: () => void; + }; } export const globalMiddleware = ({ @@ -153,7 +162,6 @@ export const globalMiddleware = ({ // Fallback to localhost if nothing found c.set("ipAddress", ipAddress ?? "127.0.0.1"); c.set("db", dbProvider); - c.set("email", new EmailModel(c)); c.set("core", { pathToMessages, @@ -176,8 +184,12 @@ export const globalMiddleware = ({ cronSecret: CONFIG.cronJobSecret, plugins: pluginsMetadata, cron: cronMetadata, + websocketManager: new WebsocketManager(c), }); + c.set("email", new EmailModel(c)); + c.set("websocket", new WebsocketModel(c)); + const user = await new SessionModel(c).getUser(); c.set("user", user); c.set("admin", null); diff --git a/packages/vitnode/src/api/models/sso.ts b/packages/vitnode/src/api/models/sso.ts index c65f52949..35aca256e 100644 --- a/packages/vitnode/src/api/models/sso.ts +++ b/packages/vitnode/src/api/models/sso.ts @@ -11,7 +11,7 @@ import { removeSpecialCharacters } from "@/lib/special-characters"; import { UserModel } from "./user"; -export interface SSOApiPlugin { +export interface SSOApiAdapter { fetchToken: ( code: string, ) => Promise<{ access_token: string; token_type: string }>; @@ -34,7 +34,7 @@ export class SSOModel { } private readonly c: Context; - private readonly plugins: SSOApiPlugin[]; + private readonly plugins: SSOApiAdapter[]; private readonly signUpUser = async ({ providerId, diff --git a/packages/vitnode/src/api/models/websocket/manager.ts b/packages/vitnode/src/api/models/websocket/manager.ts new file mode 100644 index 000000000..3a2d6b56a --- /dev/null +++ b/packages/vitnode/src/api/models/websocket/manager.ts @@ -0,0 +1,33 @@ +import type { Context } from "hono"; + +export interface WebsocketManagerConfig { + addConnection: (id: string, socket: WebSocket) => void; + removeConnection: (id: string) => void; + getConnection: (id: string) => WebSocket | undefined; + getAllConnections: () => Map; +} + +export class WebsocketManager implements WebsocketManagerConfig { + constructor(c: Context) { + this.c = c; + } + + protected readonly c: Context; + private readonly connections = new Map(); + + addConnection(id: string, socket: WebSocket) { + this.connections.set(id, socket); + } + + removeConnection(id: string) { + this.connections.delete(id); + } + + getConnection(id: string): WebSocket | undefined { + return this.connections.get(id); + } + + getAllConnections(): Map { + return this.connections; + } +} diff --git a/packages/vitnode/src/api/models/websocket/model.ts b/packages/vitnode/src/api/models/websocket/model.ts new file mode 100644 index 000000000..45366e1fb --- /dev/null +++ b/packages/vitnode/src/api/models/websocket/model.ts @@ -0,0 +1,15 @@ +import type { Context } from "hono"; + +export class WebsocketModel { + constructor(c: Context) { + this.c = c; + } + + protected readonly c: Context; + + test() { + console.log("WebsocketModel test method called"); + + const user = this.c.get("user"); + } +} diff --git a/packages/vitnode/src/hooks/use-websocket.ts b/packages/vitnode/src/hooks/use-websocket.ts new file mode 100644 index 000000000..6dd5bb83d --- /dev/null +++ b/packages/vitnode/src/hooks/use-websocket.ts @@ -0,0 +1,7 @@ +import React from "react"; + +export const WebsocketContext = React.createContext<{ + isConnected: boolean; +}>({ + isConnected: false, +}); diff --git a/packages/vitnode/src/views/layouts/provider.tsx b/packages/vitnode/src/views/layouts/provider.tsx index 790ae196e..d5f62f3c4 100644 --- a/packages/vitnode/src/views/layouts/provider.tsx +++ b/packages/vitnode/src/views/layouts/provider.tsx @@ -11,6 +11,7 @@ import type { VitNodeConfig } from "@/vitnode.config"; import { CONFIG } from "@/lib/config"; import { Toaster } from "../../components/ui/sonner"; +import { WebsocketContext } from "@/hooks/use-websocket"; export const RootProvider = ({ children, @@ -50,23 +51,25 @@ export const RootProvider = ({ enableSystem {...theme} > - - - {children} - + + + + {children} + + ); diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index ac62f308e..a77171443 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -7,9 +7,10 @@ import type React from "react"; import type { CronAdapter } from "./api/lib/cron"; import type { BuildPluginApiReturn } from "./api/lib/plugin"; import type { EmailApiPlugin } from "./api/models/email"; -import type { SSOApiPlugin } from "./api/models/sso"; +import type { SSOApiAdapter } from "./api/models/sso"; import type { DefaultTemplateEmailProps } from "./emails/default-template"; import type { BuildPluginReturn } from "./lib/plugin"; +import type { WebsocketAdapter } from "./api/lib/websocket"; export interface LocaleConfig { code: string; @@ -44,7 +45,7 @@ export interface VitNodeApiConfig { cookieSecure?: boolean; deviceCookieExpires?: number; deviceCookieName?: string; - ssoAdapters?: SSOApiPlugin[]; + ssoAdapters?: SSOApiAdapter[]; }; captcha?: { secretKey: string | undefined; @@ -52,6 +53,7 @@ export interface VitNodeApiConfig { type: "cloudflare_turnstile" | "recaptcha_v3"; }; cronAdapter?: CronAdapter; + websocketAdapter?: WebsocketAdapter; dbProvider: ReturnType; email?: { adapter?: EmailApiPlugin; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e31c8e969..ca38b4444 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,12 +35,18 @@ importers: apps/api: dependencies: + '@hono/node-ws': + specifier: ^1.2.0 + version: 1.2.0(@hono/node-server@1.19.6(hono@4.10.6))(hono@4.10.6) '@hono/zod-openapi': specifier: ^1.1.4 version: 1.1.4(hono@4.10.6)(zod@4.1.12) '@hono/zod-validator': specifier: ^0.7.4 version: 0.7.4(hono@4.10.6)(zod@4.1.12) + '@vitnode/blog': + specifier: workspace:* + version: link:../../plugins/blog '@vitnode/core': specifier: workspace:* version: link:../../packages/vitnode @@ -55,7 +61,7 @@ importers: version: 4.10.6 next-intl: specifier: ^4.5.3 - version: 4.5.3(next@16.0.3(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + version: 4.5.3(next@16.0.3(@babel/core@7.28.5)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: specifier: ^19.2.0 version: 19.2.0 @@ -152,7 +158,7 @@ importers: version: 16.0.3(@babel/core@7.28.5)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-intl: specifier: ^4.5.3 - version: 4.5.3(next@16.0.3(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + version: 4.5.3(next@16.0.3(@babel/core@7.28.5)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3) node-cron: specifier: ^4.2.1 version: 4.2.1 @@ -573,7 +579,7 @@ importers: version: 16.0.3(@babel/core@7.28.5)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-intl: specifier: ^4.5.3 - version: 4.5.3(next@16.0.3(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + version: 4.5.3(next@16.0.3(@babel/core@7.28.5)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: specifier: ^19.2.0 version: 19.2.0 @@ -642,7 +648,7 @@ importers: version: 16.0.3(@babel/core@7.28.5)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-intl: specifier: ^4.5.3 - version: 4.5.3(next@16.0.3(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + version: 4.5.3(next@16.0.3(@babel/core@7.28.5)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: specifier: ^19.2.0 version: 19.2.0 @@ -1574,6 +1580,13 @@ packages: peerDependencies: hono: ^4 + '@hono/node-ws@1.2.0': + resolution: {integrity: sha512-OBPQ8OSHBw29mj00wT/xGYtB6HY54j0fNSdVZ7gZM3TUeq0So11GXaWtFf1xWxQNfumKIsj0wRuLKWfVsO5GgQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@hono/node-server': ^1.11.1 + hono: ^4.6.0 + '@hono/swagger-ui@0.5.2': resolution: {integrity: sha512-7wxLKdb8h7JTdZ+K8DJNE3KXQMIpJejkBTQjrYlUWF28Z1PGOKw6kUykARe5NTfueIN37jbyG/sBYsbzXzG53A==} peerDependencies: @@ -8979,6 +8992,15 @@ snapshots: dependencies: hono: 4.10.6 + '@hono/node-ws@1.2.0(@hono/node-server@1.19.6(hono@4.10.6))(hono@4.10.6)': + dependencies: + '@hono/node-server': 1.19.6(hono@4.10.6) + hono: 4.10.6 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@hono/swagger-ui@0.5.2(hono@4.10.6)': dependencies: hono: 4.10.6 @@ -14212,7 +14234,7 @@ snapshots: negotiator@1.0.0: {} - next-intl@4.5.3(next@16.0.3(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + next-intl@4.5.3(next@16.0.3(@babel/core@7.28.5)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: '@formatjs/intl-localematcher': 0.5.10 '@swc/core': 1.15.2