diff --git a/.github/workflows/bump_publish.yml b/.github/workflows/bump_publish.yml index c8e5c0255..609ccb931 100644 --- a/.github/workflows/bump_publish.yml +++ b/.github/workflows/bump_publish.yml @@ -82,21 +82,21 @@ jobs: - name: Publish canary if: ${{ (github.event.inputs.mode == 'bump_and_publish' || github.event.inputs.mode == 'publish') && github.event.inputs.release == 'canary' }} - run: pnpm publish --filter create-vitnode-app --filter @vitnode/core --filter @vitnode/config --filter @vitnode/blog --tag canary --no-git-checks --access public + run: pnpm publish --filter create-vitnode-app --filter @vitnode/core --filter @vitnode/config --filter @vitnode/blog --filter @vitnode/nodemailer --filter @vitnode/resend --tag canary --no-git-checks --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true - name: Publish release candidate if: ${{ (github.event.inputs.mode == 'bump_and_publish' || github.event.inputs.mode == 'publish') && github.event.inputs.release == 'release-candidate' }} - run: pnpm publish --filter create-vitnode-app --filter @vitnode/core --filter @vitnode/config --filter @vitnode/blog --tag rc --no-git-checks --access public + run: pnpm publish --filter create-vitnode-app --filter @vitnode/core --filter @vitnode/config --filter @vitnode/blog --filter @vitnode/nodemailer --filter @vitnode/resend --tag rc --no-git-checks --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true - name: Publish stable if: ${{ (github.event.inputs.mode == 'bump_and_publish' || github.event.inputs.mode == 'publish') && github.event.inputs.release == 'stable' }} - run: pnpm publish --filter create-vitnode-app --filter @vitnode/core --filter @vitnode/config --filter @vitnode/blog --tag latest --no-git-checks --access public + run: pnpm publish --filter create-vitnode-app --filter @vitnode/core --filter @vitnode/config --filter @vitnode/blog --filter @vitnode/nodemailer --filter @vitnode/resend --tag latest --no-git-checks --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true diff --git a/apps/api/package.json b/apps/api/package.json index 7c4099122..bb3ed29fa 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -33,6 +33,7 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitnode/config": "workspace:*", + "@vitnode/nodemailer": "workspace:*", "dotenv": "^17.2.3", "eslint": "^9.39.1", "react-email": "^5.0.1", diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index 533a84faf..29274f43b 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -1,5 +1,5 @@ -import { NodemailerEmailAdapter } from "@vitnode/core/api/adapters/email/nodemailer"; import { buildApiConfig } from "@vitnode/core/vitnode.config"; +import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; import { config } from "dotenv"; import { drizzle } from "drizzle-orm/postgres-js"; diff --git a/apps/docs/content/docs/dev/captcha/captcha_preview.png b/apps/docs/content/docs/dev/captcha/captcha_preview.png new file mode 100644 index 000000000..b7d68a5ab Binary files /dev/null and b/apps/docs/content/docs/dev/captcha/captcha_preview.png differ diff --git a/apps/docs/content/docs/dev/captcha/custom-adapter.mdx b/apps/docs/content/docs/dev/captcha/custom-adapter.mdx new file mode 100644 index 000000000..0cc0e0f74 --- /dev/null +++ b/apps/docs/content/docs/dev/captcha/custom-adapter.mdx @@ -0,0 +1,116 @@ +--- +title: Custom Adapter +description: Create your own custom captcha adapter for VitNode. +--- + +If you want to use captcha in your custom form or somewhere else, follow these steps. + +## Usage + + + + +### Activate captcha in route + +```ts title="plugins/{plugin_name}/src/routes/example.ts" +import { buildRoute } from "@vitnode/core/api/lib/route"; + +export const exampleRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Create a new user", + path: "/sign_up", + withCaptcha: true, // [!code ++] + }, + handler: async c => {}, +}); +``` + + + + +### Get config from middleware API + +```tsx title="plugins/{plugin_name}/src/app/sing_up/page.tsx" +import { getMiddlewareApi } from "@vitnode/core/lib/api/get-middleware-api"; // [!code ++] + +export const SignUpView = async () => { + const { captcha } = await getMiddlewareApi(); // [!code ++] + + return ; +}; +``` + + + + +### Use `useCaptcha` hook + +Inside your client component, use the `useCaptcha` hook to handle captcha rendering and validation. Remember to add `div` with `id="vitnode_captcha"` where you want the captcha widget to appear. + +```tsx title="plugins/{plugin_name}/src/components/form/sign-up/sign-up.tsx" +"use client"; + +import { AutoForm } from "@vitnode/core/components/form/auto-form"; + +export const FormSignUp = ({ + captcha, // [!code ++] +}: { + captcha: z.infer["captcha"]; // [!code ++] +}) => { + // [!code ++] + const { isReady, getToken, onReset } = useCaptcha(captcha); + + const onSubmit = async () => { + await mutationApi({ + // ...other values, + captchaToken: await getToken(), // [!code ++] + }); + + // Handle success or error + // [!code ++] + onReset(); // Reset captcha after submission + }; + + return ( +
+ {/* Render captcha widget */} + {/* [!code ++] */} +
+ + + + ); +}; +``` + + + + +### Submit form with captcha + +```tsx title="plugins/{plugin_name}/src/components/form/sign-up/mutation-api.ts" +"use server"; + +import type { z } from "zod"; + +import { fetcher } from "@vitnode/core/lib/fetcher"; + +export const mutationApi = async ({ + captchaToken, // [!code ++] +}: { + // [!code ++] + captchaToken; +}) => { + await fetcher(usersModule, { + path: "/test", + method: "post", + module: "blog", + captchaToken, // [!code ++] + }); +}; +``` + + + diff --git a/apps/docs/content/docs/dev/captcha/index.mdx b/apps/docs/content/docs/dev/captcha/index.mdx index 0967d5b84..8841d9655 100644 --- a/apps/docs/content/docs/dev/captcha/index.mdx +++ b/apps/docs/content/docs/dev/captcha/index.mdx @@ -3,7 +3,13 @@ title: Captcha description: Protect your forms and API call with captcha validation. --- -## Support +import captchaPreview from "./captcha_preview.png"; + +import { ImgDocs } from "@/components/fumadocs/img"; + + + +## Providers VitNode supports multiple captcha providers. You can choose the one that fits your needs. Currently, we support: @@ -13,7 +19,11 @@ VitNode supports multiple captcha providers. You can choose the one that fits yo description="By Cloudflare" href="/docs/guides/captcha/cloudflare" /> - + If you need more providers, feel free to open a **Feature Request** on our [GitHub repository](https://github.com/aXenDeveloper/vitnode/issues) :) @@ -38,9 +48,9 @@ export const exampleRoute = buildRoute({ method: "post", description: "Create a new user", path: "/sign_up", - withCaptcha: true // [!code ++] + withCaptcha: true, // [!code ++] }, - handler: async (c) => {} + handler: async c => {}, }); ``` @@ -74,7 +84,7 @@ Get the `captcha` config from the props and pass it to the `AutoForm` component. import { AutoForm } from "@vitnode/core/components/form/auto-form"; export const FormSignUp = ({ - captcha // [!code ++] + captcha, // [!code ++] }: { captcha: z.infer["captcha"]; // [!code ++] }) => { @@ -106,23 +116,23 @@ In your form submission handler, you can get the `captchaToken` from the form su import { AutoForm, - type AutoFormOnSubmit // [!code ++] + type AutoFormOnSubmit, // [!code ++] } from "@vitnode/core/components/form/auto-form"; export const FormSignUp = ({ - captcha + captcha, }: { captcha: z.infer["captcha"]; }) => { const onSubmit: AutoFormOnSubmit = async ( values, form, - { captchaToken } // [!code ++] + { captchaToken }, // [!code ++] ) => { // Call your mutation API with captcha token await mutationApi({ ...values, - captchaToken // [!code ++] + captchaToken, // [!code ++] }); // Handle success or error @@ -159,8 +169,8 @@ z.infer & { captchaToken: string }) => { module: "users", captchaToken, // [!code ++] args: { - body: input - } + body: input, + }, }); if (res.status !== 201) { @@ -175,115 +185,3 @@ z.infer & { captchaToken: string }) => { - -## Custom Usage - -If you want to use captcha in your custom form or somewhere else, follow these steps. - - - - -### Activate captcha in route - -```ts title="plugins/{plugin_name}/src/routes/example.ts" -import { buildRoute } from "@vitnode/core/api/lib/route"; - -export const exampleRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - route: { - method: "post", - description: "Create a new user", - path: "/sign_up", - withCaptcha: true // [!code ++] - }, - handler: async (c) => {} -}); -``` - - - - -### Get config from middleware API - -```tsx title="plugins/{plugin_name}/src/app/sing_up/page.tsx" -import { getMiddlewareApi } from "@vitnode/core/lib/api/get-middleware-api"; // [!code ++] - -export const SignUpView = async () => { - const { captcha } = await getMiddlewareApi(); // [!code ++] - - return ; -}; -``` - - - - -### Use `useCaptcha` hook - -Inside your client component, use the `useCaptcha` hook to handle captcha rendering and validation. Remember to add `div` with `id="vitnode_captcha"` where you want the captcha widget to appear. - -```tsx title="plugins/{plugin_name}/src/components/form/sign-up/sign-up.tsx" -"use client"; - -import { AutoForm } from "@vitnode/core/components/form/auto-form"; - -export const FormSignUp = ({ - captcha // [!code ++] -}: { - captcha: z.infer["captcha"]; // [!code ++] -}) => { - // [!code ++] - const { isReady, getToken, onReset } = useCaptcha(captcha); - - const onSubmit = async () => { - await mutationApi({ - // ...other values, - captchaToken: await getToken() // [!code ++] - }); - - // Handle success or error - // [!code ++] - onReset(); // Reset captcha after submission - }; - - return ( -
- {/* Render captcha widget */} - {/* [!code ++] */} -
- - - - ); -}; -``` - - - - -### Submit form with captcha - -```tsx title="plugins/{plugin_name}/src/components/form/sign-up/mutation-api.ts" -"use server"; - -import type { z } from "zod"; - -import { fetcher } from "@vitnode/core/lib/fetcher"; - -export const mutationApi = async ({ - captchaToken // [!code ++] -}: { - // [!code ++] - captchaToken; -}) => { - await fetcher(usersModule, { - path: "/test", - method: "post", - module: "blog", - captchaToken // [!code ++] - }); -}; -``` - - - diff --git a/apps/docs/content/docs/dev/captcha/meta.json b/apps/docs/content/docs/dev/captcha/meta.json new file mode 100644 index 000000000..f9977456b --- /dev/null +++ b/apps/docs/content/docs/dev/captcha/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Captcha", + "pages": ["...", "custom-adapter"] +} diff --git a/apps/docs/content/docs/dev/cron/custom-adapter.mdx b/apps/docs/content/docs/dev/cron/custom-adapter.mdx new file mode 100644 index 000000000..14c461303 --- /dev/null +++ b/apps/docs/content/docs/dev/cron/custom-adapter.mdx @@ -0,0 +1,83 @@ +--- +title: Custom Adapter +description: Create your own custom cron adapter for VitNode. +--- + +VitNode supports custom cron adapters, allowing you to integrate with various scheduling libraries or services. + +## Usage + + + + +### Create your custom adapter + +As an example we will create a custom adapter using the popular `node-cron` library. + +```ts +import { schedule } from "node-cron"; +import { type CronAdapter, handleCronJobs } from "@/api/lib/cron"; + +export const NodeCronAdapter = (): CronAdapter => { + return { + schedule() { + schedule("*/1 * * * *", async () => { + await handleCronJobs(); // [!code ++] + }); + }, + }; +}; +``` + + + + + +### Integrate the adapter into your application + +```ts title="src/vitnode.api.config.ts" +import { NodeCronAdapter } from "./path/to/your/custom/node-cron.adapter"; + +export const vitNodeApiConfig = buildApiConfig({ + cronAdapter: NodeCronAdapter(), +}); +``` + + + + + +### Restart server + +After making these changes, stop your server (if it's running) and restart it to apply the new configuration. + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + +That's it — your app now has a built-in task scheduler, ready to handle cron jobs with standard cron expressions. + + + + +### Check Your Cron Jobs + +You can check your cron jobs in AdminCP under `Core` => `Advanced` => `Cron Jobs`. + + + + diff --git a/apps/docs/content/docs/dev/cron/index.mdx b/apps/docs/content/docs/dev/cron/index.mdx index 5be6d1c73..8b0dacb25 100644 --- a/apps/docs/content/docs/dev/cron/index.mdx +++ b/apps/docs/content/docs/dev/cron/index.mdx @@ -20,80 +20,52 @@ Before you can use cron functionality, you need to provide an adapter to your ap /> -## Custom adapter - -VitNode supports custom cron adapters, allowing you to integrate with various scheduling libraries or services. +## Usage - -### Create your custom adapter - -As an example we will create a custom adapter using the popular `node-cron` library. - -```ts -import { schedule } from "node-cron"; -import { type CronAdapter, handleCronJobs } from "@/api/lib/cron"; - -export const NodeCronAdapter = (): CronAdapter => { - return { - schedule() { - schedule("*/1 * * * *", async () => { - await handleCronJobs(); // [!code ++] - }); - } - }; -}; -``` - - - - - -### Integrate the adapter into your application - -```ts title="src/vitnode.api.config.ts" -import { NodeCronAdapter } from "./path/to/your/custom/node-cron.adapter"; - -export const vitNodeApiConfig = buildApiConfig({ - cronAdapter: NodeCronAdapter() +### Create CRON file + +```ts title="cron/clean.cron.ts" +import { buildCron } from "@vitnode/core/api/lib/cron"; + +export const cleanCron = buildCron({ + name: "clean", + description: "Clean up expired sessions and tokens", + // Run every 1 hour + schedule: "0 * * * *", + handler: async c => { + console.log("Running cleanup cron job..."); + }, }); ``` - +### Register CRON in module -## Restart server - -After making these changes, stop your server (if it's running) and restart it to apply the new configuration. +```ts title="modules/clean/clean.module.ts" +import { buildModule } from "@vitnode/core/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; -import { Tab, Tabs } from "fumadocs-ui/components/tabs"; - - - -```bash tab="bun" -bun dev -``` +// [!code ++] +import { cleanCron } from "./cron/clean.cron"; -```bash tab="pnpm" -pnpm dev -``` - -```bash tab="npm" -npm run dev +export const cronModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "clean", + routes: [], + // [!code ++] + cronJobs: [cleanCron], +}); ``` - - -That's it — your app now has a built-in task scheduler, ready to handle cron jobs with standard cron expressions. - -## Check Your Cron Jobs +### Check Your Cron Job -You can check your cron jobs in AdminCP under **Core => Advanced => Cron Jobs**. +When your CRON job will run first time, you should see your job in AdminCP under `Core` => `Advanced` => `Cron Jobs`. diff --git a/apps/docs/content/docs/dev/cron/meta.json b/apps/docs/content/docs/dev/cron/meta.json index f5df12b36..42ae35c6f 100644 --- a/apps/docs/content/docs/dev/cron/meta.json +++ b/apps/docs/content/docs/dev/cron/meta.json @@ -1,4 +1,4 @@ { "title": "CRON Jobs", - "pages": ["rest-api", "..."] + "pages": ["rest-api", "...", "custom-adapter"] } diff --git a/apps/docs/content/docs/dev/cron/node-cron.mdx b/apps/docs/content/docs/dev/cron/node-cron.mdx index cfe76f380..2a5f75706 100644 --- a/apps/docs/content/docs/dev/cron/node-cron.mdx +++ b/apps/docs/content/docs/dev/cron/node-cron.mdx @@ -5,10 +5,9 @@ description: In-memory tiny task scheduler in pure JavaScript for node.js based This adapter lets you run scheduled jobs directly inside your Node.js app. It's simple, lightweight, and doesn't require any external services — great for when you just need cron tasks running locally or in memory. - - This documentation is for self-hosted VitNode instances only. You cannot use this if you are - planning to deploy your application to the cloud. - +| Cloud | Self-Hosted | Links | +| ---------------- | ------------ | ------------------------------------------------------ | +| ❌ Not Supported | ✅ Supported | [NPM Package](https://www.npmjs.com/package/node-cron) | @@ -16,18 +15,18 @@ This adapter lets you run scheduled jobs directly inside your Node.js app. It's import { Tab, Tabs } from "fumadocs-ui/components/tabs"; - + ```bash tab="bun" -bun i node-cron +bun i @vitnode/node-cron -D ``` ```bash tab="pnpm" -pnpm i node-cron +pnpm i @vitnode/node-cron -D ``` ```bash tab="npm" -npm i node-cron +npm i @vitnode/node-cron -D ``` @@ -37,12 +36,13 @@ npm i node-cron ## Usage ```ts title="src/vitnode.api.config.ts" -import { NodeCronAdapter } from "@vitnode/core/api/adapters/cron/node-cron.adapter"; -``` +// [!code ++] +import { NodeCronAdapter } from "@vitnode/node-cron"; +import { buildApiConfig } from "@vitnode/core/vitnode.config"; -```ts title="src/vitnode.api.config.ts" export const vitNodeApiConfig = buildApiConfig({ - cronAdapter: NodeCronAdapter() + // [!code ++] + cronAdapter: NodeCronAdapter(), }); ``` @@ -54,7 +54,7 @@ export const vitNodeApiConfig = buildApiConfig({ After making these changes, stop your server (if it's running) and restart it to apply the new configuration. - + ```bash tab="bun" bun dev @@ -77,7 +77,7 @@ That's it — your app now has a built-in task scheduler, ready to handle cron j ## Check Your Cron Jobs -You can check your cron jobs in AdminCP under **Core => Advanced => Cron Jobs**. +You can check your cron jobs in AdminCP under `Core` => `Advanced` => `Cron Jobs`. diff --git a/apps/docs/content/docs/dev/cron/rest-api.mdx b/apps/docs/content/docs/dev/cron/rest-api.mdx index e4d6e1b95..d28901c74 100644 --- a/apps/docs/content/docs/dev/cron/rest-api.mdx +++ b/apps/docs/content/docs/dev/cron/rest-api.mdx @@ -5,6 +5,10 @@ description: Run cron jobs by triggering REST API endpoints from an external sch This method lets you use external services to manage and run your cron jobs through simple HTTP requests. It's flexible and works with many providers, so you can pick the scheduling tool that best fits your infrastructure. +| Cloud | Self-Hosted | +| ------------ | ------------ | +| ✅ Supported | ✅ Supported | + ## Add a Secret Key @@ -18,7 +22,8 @@ CRON_SECRET=your_secret_key ``` - We recommend using a random string of at least **16 characters** for better security. + We recommend using a random string of at least **16 characters** for better + security. @@ -61,7 +66,7 @@ Replace `https://your-domain.com/api/cron` with your actual domain and `{{your_k ## Check Your Cron Jobs -You can check your cron jobs in AdminCP under **Core => Advanced => Cron Jobs**. +You can check your cron jobs in AdminCP under `Core` => `Advanced` => `Cron Jobs`. diff --git a/apps/docs/content/docs/dev/email/custom-adapter.mdx b/apps/docs/content/docs/dev/email/custom-adapter.mdx new file mode 100644 index 000000000..109eabe40 --- /dev/null +++ b/apps/docs/content/docs/dev/email/custom-adapter.mdx @@ -0,0 +1,95 @@ +--- +title: Custom Adapter +description: Create your own custom email adapter for VitNode. +--- + +Want to create your own email adapter? You can do it by implementing the `EmailApiPlugin` interface. This allows you to define how emails are sent in your application. + +## Usage + + + +### Create adapter + +Here is your template for a custom email adapter. + +```ts title="src/utils/email/mailer.ts" +import type { EmailApiPlugin } from "@vitnode/core/api/models/email"; + +export const MailerEmailAdapter = (): EmailApiPlugin => {}; +``` + + + + +### Add config + +If you want to provide config for you adapter, you can do it like this: + +```ts title="src/utils/email/mailer.ts" +import type { EmailApiPlugin } from "@vitnode/core/api/models/email"; + +export const MailerEmailAdapter = ({ + // [!code ++:13] + host = "", + port = 587, + secure = false, + user = "", + password = "", + from = "", +}: { + from: string | undefined; + host: string | undefined; + password: string | undefined; + port?: number; + secure?: boolean; + user: string | undefined; +}): EmailApiPlugin => {}; +``` + + + + + +### Add `sendEmail()` method + +Implement the `sendEmail()` method to send emails using your custom logic. You can use any email sending library or service. + +```ts title="src/utils/email/mailer.ts" +import type { EmailApiPlugin } from "@vitnode/core/api/models/email"; + +export const MailerEmailAdapter = ({ + host = "", + port = 587, + secure = false, + user = "", + password = "", + from = "", +}: { + from: string | undefined; + host: string | undefined; + password: string | undefined; + port?: number; + secure?: boolean; + user: string | undefined; +}): EmailApiPlugin => { + // [!code ++:3] + return { + sendEmail: async ({ metadata, to, subject, html, replyTo, text }) => {}, + }; +}; +``` + + + + +## Publish to NPM + +If you want to share your custom adapter with the community, you can publish it as an NPM package. + +Example source code for NPM packages: + +- [Resend Adapter](https://github.com/aXenDeveloper/vitnode/tree/canary/packages/resend), +- [Nodemailer Adapter](https://github.com/aXenDeveloper/vitnode/tree/canary/packages/nodemailer) + +Make sure to follow best practices for package development and include proper documentation. diff --git a/apps/docs/content/docs/dev/email/index.mdx b/apps/docs/content/docs/dev/email/index.mdx index b63e75607..7e132717c 100644 --- a/apps/docs/content/docs/dev/email/index.mdx +++ b/apps/docs/content/docs/dev/email/index.mdx @@ -9,10 +9,10 @@ Before you can use email functionality, you need to provide an adapter to your a - + -or create your own [custom email adapter](/docs/dev/email/overview#custom-email-adapter)... +or create your own [custom email adapter](/docs/dev/email/custom-adapter). ## Usage @@ -24,10 +24,10 @@ import { buildRoute } from "@vitnode/core/api/lib/route"; import { UserModel } from "@vitnode/core/api/models/user"; export const testRoute = buildRoute({ - handler: async (c) => { + handler: async c => { const user = await new UserModel().getUserById({ id: 3, - c + c, }); if (!user) throw new Error("User not found"); @@ -36,11 +36,11 @@ export const testRoute = buildRoute({ await c.get("email").send({ subject: "Test Email", content: () => "This is a test email.", - user + user, }); return c.text("test"); - } + }, }); ``` @@ -51,96 +51,16 @@ import { z } from "zod"; import { buildRoute } from "@vitnode/core/api/lib/route"; export const testRoute = buildRoute({ - handler: async (c) => { + handler: async c => { // [!code ++:6] await c.get("email").send({ to: "test@test.com", subject: "Test Email", content: () => "This is a test email.", - locale: "en" + locale: "en", }); return c.text("test"); - } + }, }); ``` - -## Custom Email Adapter - -Want to create your own email adapter? You can do it by implementing the `EmailApiPlugin` interface. This allows you to define how emails are sent in your application. - - - -### Create adapter - -Here is your template for a custom email adapter. - -```ts title="src/utils/email/mailer.ts" -import type { EmailApiPlugin } from "@vitnode/core/api/models/email"; - -export const MailerEmailAdapter = (): EmailApiPlugin => {}; -``` - - - - -### Add config - -If you want to provide config for you adapter, you can do it like this: - -```ts title="src/utils/email/mailer.ts" -import type { EmailApiPlugin } from "@vitnode/core/api/models/email"; - -export const MailerEmailAdapter = ({ - // [!code ++:13] - host = "", - port = 587, - secure = false, - user = "", - password = "", - from = "" -}: { - from: string | undefined; - host: string | undefined; - password: string | undefined; - port?: number; - secure?: boolean; - user: string | undefined; -}): EmailApiPlugin => {}; -``` - - - - - -### Add `sendEmail()` method - -Implement the `sendEmail()` method to send emails using your custom logic. You can use any email sending library or service. - -```ts title="src/utils/email/mailer.ts" -import type { EmailApiPlugin } from "@vitnode/core/api/models/email"; - -export const MailerEmailAdapter = ({ - host = "", - port = 587, - secure = false, - user = "", - password = "", - from = "" -}: { - from: string | undefined; - host: string | undefined; - password: string | undefined; - port?: number; - secure?: boolean; - user: string | undefined; -}): EmailApiPlugin => { - // [!code ++:3] - return { - sendEmail: async ({ metadata, to, subject, html, replyTo, text }) => {} - }; -}; -``` - - - diff --git a/apps/docs/content/docs/dev/email/meta.json b/apps/docs/content/docs/dev/email/meta.json index 7d2940a6b..a0d3641b4 100644 --- a/apps/docs/content/docs/dev/email/meta.json +++ b/apps/docs/content/docs/dev/email/meta.json @@ -1,4 +1,10 @@ { "title": "Email", - "pages": ["templates", "components", "---Adapters---", "..."] + "pages": [ + "templates", + "components", + "---Adapters---", + "...", + "custom-adapter" + ] } diff --git a/apps/docs/content/docs/dev/email/nodemailer.mdx b/apps/docs/content/docs/dev/email/nodemailer.mdx new file mode 100644 index 000000000..5cb348ad7 --- /dev/null +++ b/apps/docs/content/docs/dev/email/nodemailer.mdx @@ -0,0 +1,71 @@ +--- +title: Nodemailer (SMTP) +description: Send emails using SMTP with the Nodemailer adapter. +--- + +| Cloud | Self-Hosted | Links | +| ---------------- | ------------ | ------------------------------------------------------- | +| ❌ Not Supported | ✅ Supported | [NPM Package](https://www.npmjs.com/package/nodemailer) | + +## Usage + + + +### Installation + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + + + +```bash tab="bun" +bun i @vitnode/nodemailer -D +``` + +```bash tab="pnpm" +pnpm i @vitnode/nodemailer -D +``` + +```bash tab="npm" +npm i @vitnode/nodemailer -D +``` + + + + + +### Import the adapter + +```ts title="vitnode.api.config.ts" +// [!code ++] +import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; +import { buildApiConfig } from "@vitnode/core/vitnode.config"; + +export const vitNodeApiConfig = buildApiConfig({ + email: { + // [!code ++:6] + adapter: NodemailerEmailAdapter({ + from: process.env.NODE_MAILER_FROM, + host: process.env.NODE_MAILER_HOST, + password: process.env.NODE_MAILER_PASSWORD, + user: process.env.NOD_EMAILER_USER, + }), + }, +}); +``` + + + + +### Environment Variables + +Add the following environment variables to your `.env` file: + +```bash title=".env" +NODE_MAILER_FROM=your_verified_email +NODE_MAILER_HOST=smtp.your-email-provider.com +NODE_MAILER_PASSWORD=your_email_password +NOD_EMAILER_USER=your_email_username +``` + + + diff --git a/apps/docs/content/docs/dev/email/resend.mdx b/apps/docs/content/docs/dev/email/resend.mdx index 2355f7c04..abe8a3b84 100644 --- a/apps/docs/content/docs/dev/email/resend.mdx +++ b/apps/docs/content/docs/dev/email/resend.mdx @@ -1,8 +1,67 @@ --- title: Resend -description: How to use Resend for sending emails in your application. +description: Send emails using Resend with the Resend adapter. --- - - We're working hard to bring you the best documentation experience. - +| Cloud | Self-Hosted | Links | +| ------------ | ------------ | --------------------------------------------------- | +| ✅ Supported | ✅ Supported | [NPM Package](https://www.npmjs.com/package/resend) | + +## Usage + + + +### Installation + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + + + +```bash tab="bun" +bun i @vitnode/resend -D +``` + +```bash tab="pnpm" +pnpm i @vitnode/resend -D +``` + +```bash tab="npm" +npm i @vitnode/resend -D +``` + + + + + +### Import the adapter + +```ts title="vitnode.api.config.ts" +// [!code ++] +import { ResendEmailAdapter } from "@vitnode/resend"; +import { buildApiConfig } from "@vitnode/core/vitnode.config"; + +export const vitNodeApiConfig = buildApiConfig({ + email: { + // [!code ++:4] + adapter: ResendEmailAdapter({ + apiKey: process.env.RESEND_API_KEY, + from: process.env.RESEND_FROM_EMAIL, + }), + }, +}); +``` + + + + +### Environment Variables + +Add the following environment variables to your `.env` file: + +```bash title=".env" +RESEND_API_KEY=your_resend_api_key +RESEND_FROM_EMAIL=your_verified_resend_email +``` + + + diff --git a/apps/docs/content/docs/dev/email/smtp.mdx b/apps/docs/content/docs/dev/email/smtp.mdx deleted file mode 100644 index a1386ec4a..000000000 --- a/apps/docs/content/docs/dev/email/smtp.mdx +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: SMTP -description: SMTP configuration for sending emails ---- - - - This documentation is for self-hosted VitNode instances only. You cannot use - this if you are planning to deploy your application to the cloud. - - - - We're working hard to bring you the best documentation experience. - diff --git a/apps/docs/content/docs/dev/sso/custom-adapter.mdx b/apps/docs/content/docs/dev/sso/custom-adapter.mdx new file mode 100644 index 000000000..45a8feeb3 --- /dev/null +++ b/apps/docs/content/docs/dev/sso/custom-adapter.mdx @@ -0,0 +1,352 @@ +--- +title: Custom Adapter +description: Create your own custom SSO adapter for VitNode. +--- + +Want to let your users sign in with their favorite services? Let's build a custom SSO adapter! We'll use Discord as an example, but you can adapt this guide for any OAuth2 provider. + +## Usage + +import { Callout } from "fumadocs-ui/components/callout"; + + + + ### Create Your SSO Plugin + +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"; + +export const DiscordSSOApiPlugin = ({ + clientId, + clientSecret, +}: { + clientId: string; + clientSecret: string; +}): SSOApiPlugin => { + const id = "discord"; + const redirectUri = getRedirectUri(id); + + return { id, name: "Discord" }; +}; +``` + +This is like creating a blueprint for your SSO provider. The `id` will be used in URLs and the `name` is what users will see. + + + + +### Add Authentication URL Generator + +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"; + +export const DiscordSSOApiPlugin = ({ + clientId, + clientSecret, +}: { + clientId: string; + clientSecret: string; +}): SSOApiPlugin => { + const id = "discord"; + const redirectUri = getRedirectUri(id); + + return { + id, + name: "Discord", + // [!code ++] + getUrl: ({ state }) => { + // [!code ++] + const url = new URL("https://discord.com/oauth2/authorize"); + // [!code ++] + url.searchParams.set("client_id", clientId); + // [!code ++] + url.searchParams.set("redirect_uri", redirectUri); + // [!code ++] + url.searchParams.set("response_type", "code"); + // [!code ++] + url.searchParams.set("scope", "identify email"); + // [!code ++] + url.searchParams.set("state", state); + // [!code ++] + return url.toString(); + // [!code ++] + }, + }; +}; +``` + + + Always include the `state` parameter - it's your security guard against CSRF + attacks. Don't worry, VitNode handles this automatically! + + + + + + +### Handle Token Exchange + +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 { HTTPException } from "hono/http-exception"; +import { ContentfulStatusCode } from "hono/utils/http-status"; +import { z } from "zod"; + +const tokenSchema = z.object({ + access_token: z.string(), + token_type: z.string(), +}); + +export const DiscordSSOApiPlugin = ({ + clientId, + clientSecret, +}: { + clientId: string; + clientSecret: string; +}): SSOApiPlugin => { + const id = "discord"; + const redirectUri = getRedirectUri(id); + + return { + id, + name: "Discord", + // [!code ++] + fetchToken: async code => { + // [!code ++] + const res = await fetch("https://discord.com/api/oauth2/token", { + // [!code ++] + method: "POST", + // [!code ++] + headers: { + // [!code ++] + "Content-Type": "application/x-www-form-urlencoded", + // [!code ++] + Accept: "application/json", + // [!code ++] + }, + // [!code ++] + body: new URLSearchParams({ + // [!code ++] + code, + // [!code ++] + redirect_uri: redirectUri, + // [!code ++] + grant_type: "authorization_code", + // [!code ++] + client_id: clientId, + // [!code ++] + client_secret: clientSecret, + // [!code ++] + }), + // [!code ++] + }); + + // [!code ++] + if (!res.ok) { + // [!code ++] + throw new HTTPException( + // [!code ++] + +res.status.toString() as ContentfulStatusCode, + // [!code ++] + { + // [!code ++] + message: "Internal error requesting token", + // [!code ++] + }, + // [!code ++] + ); + // [!code ++] + } + + // [!code ++] + const { data, error } = tokenSchema.safeParse(await res.json()); + // [!code ++] + if (error ?? !data) { + // [!code ++] + throw new HTTPException(400, { + // [!code ++] + message: "Invalid token response", + // [!code ++] + }); + // [!code ++] + } + + // [!code ++] + return data; + // [!code ++] + }, + getUrl: ({ state }) => { + const url = new URL("https://discord.com/oauth2/authorize"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "identify email"); + url.searchParams.set("state", state); + + return url.toString(); + }, + }; +}; +``` + + + + + +### Get User Information + +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 { HTTPException } from "hono/http-exception"; +import { ContentfulStatusCode } from "hono/utils/http-status"; +import { z } from "zod"; + +const userSchema = z.object({ + id: z.number(), + email: z.string(), + username: z.string(), +}); + +export const DiscordSSOApiPlugin = ({ + clientId, + clientSecret, +}: { + clientId: string; + clientSecret: string; +}): SSOApiPlugin => { + const id = "discord"; + const redirectUri = getRedirectUri(id); + + return { + id, + name: "Discord", + // [!code ++] + fetchUser: async ({ token_type, access_token }) => { + // [!code ++] + const res = await fetch("https://discord.com/api/users/@me", { + // [!code ++] + headers: { + // [!code ++] + Authorization: `${token_type} ${access_token}`, + // [!code ++] + }, + // [!code ++] + }); + + // [!code ++] + const { data, error } = userSchema.safeParse(await res.json()); + // [!code ++] + if (error ?? !data) { + // [!code ++] + throw new HTTPException(400, { + // [!code ++] + message: "Invalid user response", + // [!code ++] + }); + // [!code ++] + } + + // [!code ++] + return data; + // [!code ++] + }, + fetchToken: async code => { + const res = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + code, + redirect_uri: redirectUri, + grant_type: "authorization_code", + client_id: clientId, + client_secret: clientSecret, + }), + }); + + if (!res.ok) { + throw new HTTPException( + +res.status.toString() as ContentfulStatusCode, + { + message: "Internal error requesting token", + }, + ); + } + + const { data, error } = tokenSchema.safeParse(await res.json()); + if (error ?? !data) { + throw new HTTPException(400, { + message: "Invalid token response", + }); + } + + return data; + }, + getUrl: ({ state }) => { + const url = new URL("https://discord.com/oauth2/authorize"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "identify email"); + url.searchParams.set("state", state); + + return url.toString(); + }, + }; +}; +``` + + + Pro tip: Some OAuth providers might return unverified email addresses. If your + provider gives you an email verification status, add it to your validation to + keep things secure! + + + + + + +## Connect Everything Together + +Last step! Let's plug your new SSO provider into your app: + +```ts title="src/app/api/[...route]/route.ts" +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"; + +const app = new OpenAPIHono().basePath("/api"); +VitNodeAPI({ + app, + plugins: [], + authorization: { + // [!code ++] + ssoAdapters: [ + // [!code ++] + DiscordSSOApiPlugin({ + // [!code ++] + clientId: process.env.DISCORD_CLIENT_ID, + // [!code ++] + clientSecret: process.env.DISCORD_CLIENT_SECRET, + // [!code ++] + }), + // [!code ++] + ], + }, +}); +``` + + + + diff --git a/apps/docs/content/docs/dev/sso/index.mdx b/apps/docs/content/docs/dev/sso/index.mdx index 4c2fa1875..5372ccb1e 100644 --- a/apps/docs/content/docs/dev/sso/index.mdx +++ b/apps/docs/content/docs/dev/sso/index.mdx @@ -22,347 +22,3 @@ Before you can use SSO, you need to provide an adapter to your application. or create your own custom SSO adapter... - -## Custom SSO Adapter - -Want to let your users sign in with their favorite services? Let's build a custom SSO adapter! We'll use Discord as an example, but you can adapt this guide for any OAuth2 provider. - -import { Callout } from "fumadocs-ui/components/callout"; - - - - ### Create Your SSO Plugin - -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"; - -export const DiscordSSOApiPlugin = ({ - clientId, - clientSecret, -}: { - clientId: string; - clientSecret: string; -}): SSOApiPlugin => { - const id = "discord"; - const redirectUri = getRedirectUri(id); - - return { id, name: "Discord" }; -}; -``` - -This is like creating a blueprint for your SSO provider. The `id` will be used in URLs and the `name` is what users will see. - - - - -### Add Authentication URL Generator - -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"; - -export const DiscordSSOApiPlugin = ({ - clientId, - clientSecret, -}: { - clientId: string; - clientSecret: string; -}): SSOApiPlugin => { - const id = "discord"; - const redirectUri = getRedirectUri(id); - - return { - id, - name: "Discord", - // [!code ++] - getUrl: ({ state }) => { - // [!code ++] - const url = new URL("https://discord.com/oauth2/authorize"); - // [!code ++] - url.searchParams.set("client_id", clientId); - // [!code ++] - url.searchParams.set("redirect_uri", redirectUri); - // [!code ++] - url.searchParams.set("response_type", "code"); - // [!code ++] - url.searchParams.set("scope", "identify email"); - // [!code ++] - url.searchParams.set("state", state); - // [!code ++] - return url.toString(); - // [!code ++] - }, - }; -}; -``` - - - Always include the `state` parameter - it's your security guard against CSRF - attacks. Don't worry, VitNode handles this automatically! - - - - - - -### Handle Token Exchange - -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 { HTTPException } from "hono/http-exception"; -import { ContentfulStatusCode } from "hono/utils/http-status"; -import { z } from "zod"; - -const tokenSchema = z.object({ - access_token: z.string(), - token_type: z.string(), -}); - -export const DiscordSSOApiPlugin = ({ - clientId, - clientSecret, -}: { - clientId: string; - clientSecret: string; -}): SSOApiPlugin => { - const id = "discord"; - const redirectUri = getRedirectUri(id); - - return { - id, - name: "Discord", - // [!code ++] - fetchToken: async code => { - // [!code ++] - const res = await fetch("https://discord.com/api/oauth2/token", { - // [!code ++] - method: "POST", - // [!code ++] - headers: { - // [!code ++] - "Content-Type": "application/x-www-form-urlencoded", - // [!code ++] - Accept: "application/json", - // [!code ++] - }, - // [!code ++] - body: new URLSearchParams({ - // [!code ++] - code, - // [!code ++] - redirect_uri: redirectUri, - // [!code ++] - grant_type: "authorization_code", - // [!code ++] - client_id: clientId, - // [!code ++] - client_secret: clientSecret, - // [!code ++] - }), - // [!code ++] - }); - - // [!code ++] - if (!res.ok) { - // [!code ++] - throw new HTTPException( - // [!code ++] - +res.status.toString() as ContentfulStatusCode, - // [!code ++] - { - // [!code ++] - message: "Internal error requesting token", - // [!code ++] - }, - // [!code ++] - ); - // [!code ++] - } - - // [!code ++] - const { data, error } = tokenSchema.safeParse(await res.json()); - // [!code ++] - if (error ?? !data) { - // [!code ++] - throw new HTTPException(400, { - // [!code ++] - message: "Invalid token response", - // [!code ++] - }); - // [!code ++] - } - - // [!code ++] - return data; - // [!code ++] - }, - getUrl: ({ state }) => { - const url = new URL("https://discord.com/oauth2/authorize"); - url.searchParams.set("client_id", clientId); - url.searchParams.set("redirect_uri", redirectUri); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", "identify email"); - url.searchParams.set("state", state); - - return url.toString(); - }, - }; -}; -``` - - - - - -### Get User Information - -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 { HTTPException } from "hono/http-exception"; -import { ContentfulStatusCode } from "hono/utils/http-status"; -import { z } from "zod"; - -const userSchema = z.object({ - id: z.number(), - email: z.string(), - username: z.string(), -}); - -export const DiscordSSOApiPlugin = ({ - clientId, - clientSecret, -}: { - clientId: string; - clientSecret: string; -}): SSOApiPlugin => { - const id = "discord"; - const redirectUri = getRedirectUri(id); - - return { - id, - name: "Discord", - // [!code ++] - fetchUser: async ({ token_type, access_token }) => { - // [!code ++] - const res = await fetch("https://discord.com/api/users/@me", { - // [!code ++] - headers: { - // [!code ++] - Authorization: `${token_type} ${access_token}`, - // [!code ++] - }, - // [!code ++] - }); - - // [!code ++] - const { data, error } = userSchema.safeParse(await res.json()); - // [!code ++] - if (error ?? !data) { - // [!code ++] - throw new HTTPException(400, { - // [!code ++] - message: "Invalid user response", - // [!code ++] - }); - // [!code ++] - } - - // [!code ++] - return data; - // [!code ++] - }, - fetchToken: async code => { - const res = await fetch("https://discord.com/api/oauth2/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - code, - redirect_uri: redirectUri, - grant_type: "authorization_code", - client_id: clientId, - client_secret: clientSecret, - }), - }); - - if (!res.ok) { - throw new HTTPException( - +res.status.toString() as ContentfulStatusCode, - { - message: "Internal error requesting token", - }, - ); - } - - const { data, error } = tokenSchema.safeParse(await res.json()); - if (error ?? !data) { - throw new HTTPException(400, { - message: "Invalid token response", - }); - } - - return data; - }, - getUrl: ({ state }) => { - const url = new URL("https://discord.com/oauth2/authorize"); - url.searchParams.set("client_id", clientId); - url.searchParams.set("redirect_uri", redirectUri); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", "identify email"); - url.searchParams.set("state", state); - - return url.toString(); - }, - }; -}; -``` - - - Pro tip: Some OAuth providers might return unverified email addresses. If your - provider gives you an email verification status, add it to your validation to - keep things secure! - - - - - - -## Connect Everything Together - -Last step! Let's plug your new SSO provider into your app: - -```ts title="src/app/api/[...route]/route.ts" -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"; - -const app = new OpenAPIHono().basePath("/api"); -VitNodeAPI({ - app, - plugins: [], - authorization: { - // [!code ++] - ssoAdapters: [ - // [!code ++] - DiscordSSOApiPlugin({ - // [!code ++] - clientId: process.env.DISCORD_CLIENT_ID, - // [!code ++] - clientSecret: process.env.DISCORD_CLIENT_SECRET, - // [!code ++] - }), - // [!code ++] - ], - }, -}); -``` diff --git a/apps/docs/content/docs/dev/sso/meta.json b/apps/docs/content/docs/dev/sso/meta.json new file mode 100644 index 000000000..c4b976e82 --- /dev/null +++ b/apps/docs/content/docs/dev/sso/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Single Sign-On (SSO)", + "pages": ["...", "custom-adapter"] +} diff --git a/apps/docs/eslint.config.mjs b/apps/docs/eslint.config.mjs index ca0cefa60..d190aca3c 100644 --- a/apps/docs/eslint.config.mjs +++ b/apps/docs/eslint.config.mjs @@ -1,4 +1,5 @@ import eslintVitNode from "@vitnode/config/eslint"; +import eslintVitNodeReact from "@vitnode/config/eslint.react"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; @@ -6,6 +7,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default [ ...eslintVitNode, + ...eslintVitNodeReact, { ignores: [".source"], }, diff --git a/apps/docs/package.json b/apps/docs/package.json index fff357ac4..633557eba 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -53,6 +53,9 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitnode/config": "workspace:*", + "@vitnode/nodemailer": "workspace:*", + "@vitnode/resend": "workspace:*", + "@vitnode/node-cron": "workspace:*", "babel-plugin-react-compiler": "^1.0.0", "class-variance-authority": "^0.7.1", "eslint": "^9.39.1", diff --git a/apps/docs/src/vitnode.api.config.ts b/apps/docs/src/vitnode.api.config.ts index 1b654bc54..49cb91b54 100644 --- a/apps/docs/src/vitnode.api.config.ts +++ b/apps/docs/src/vitnode.api.config.ts @@ -1,10 +1,11 @@ import { blogApiPlugin } from "@vitnode/blog/config.api"; -import { NodeCronAdapter } from "@vitnode/core/api/adapters/cron/node-cron.adapter"; -import { NodemailerEmailAdapter } from "@vitnode/core/api/adapters/email/nodemailer"; import { DiscordSSOApiPlugin } 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 { buildApiConfig } from "@vitnode/core/vitnode.config"; +import { NodeCronAdapter } from "@vitnode/node-cron"; +import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; import { drizzle } from "drizzle-orm/postgres-js"; export const POSTGRES_URL = @@ -38,6 +39,10 @@ export const vitNodeApiConfig = buildApiConfig({ password: process.env.NODE_MAILER_PASSWORD, user: process.env.NOD_EMAILER_USER, }), + // adapter: ResendEmailAdapter({ + // apiKey: process.env.RESEND_API_KEY, + // from: process.env.RESEND_FROM_EMAIL, + // }), logo: { text: "VitNode Email Test", src: "http://localhost:3000/logo_vitnode_dark.png", diff --git a/packages/config/eslint.config.mjs b/packages/config/eslint.config.mjs index b5db2b41d..119f92706 100644 --- a/packages/config/eslint.config.mjs +++ b/packages/config/eslint.config.mjs @@ -1,21 +1,11 @@ // @ts-check -import { dirname } from "node:path"; -import { fileURLToPath } from "node:url"; import eslint from "@eslint/js"; -import eslintReact from "@eslint-react/eslint-plugin"; -import jsxA11y from "eslint-plugin-jsx-a11y"; import perfectionist from "eslint-plugin-perfectionist"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; -import reactPlugin from "eslint-plugin-react"; -import hooksPlugin from "eslint-plugin-react-hooks"; import tsEslint from "typescript-eslint"; -import reactYouMightNotNeedAnEffect from "eslint-plugin-react-you-might-not-need-an-effect"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); export default [ - reactYouMightNotNeedAnEffect.configs.recommended, { ignores: [ "next-env.d.ts", @@ -36,43 +26,13 @@ export default [ ], }, eslint.configs.recommended, - eslintReact.configs.recommended, ...tsEslint.configs.stylisticTypeChecked, ...tsEslint.configs.strictTypeChecked, eslintPluginPrettierRecommended, - jsxA11y.flatConfigs.recommended, - reactPlugin.configs.flat.recommended, perfectionist.configs["recommended-natural"], - { - files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], - settings: { - react: { - version: "detect", - }, - }, - languageOptions: { - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - }, - }, - }, - { - plugins: { - "react-hooks": hooksPlugin, - }, - rules: { - "react/react-in-jsx-scope": "off", - ...hooksPlugin.configs.recommended.rules, - }, - }, { files: ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"] }, { rules: { - "react-hooks/exhaustive-deps": "off", - "@eslint-react/no-context-provider": "off", - "@eslint-react/no-unstable-default-props": "off", "perfectionist/sort-array-includes": "warn", "@typescript-eslint/consistent-type-imports": "error", "@typescript-eslint/no-confusing-void-expression": "off", @@ -143,30 +103,10 @@ export default [ "newline-before-return": "warn", "no-restricted-imports": [ "error", - { - name: "next/link", - message: "Please import from `vitnode-frontend/navigation` instead.", - }, { name: "drizzle-orm/mysql-core", message: "Please import from `drizzle-orm/pg-core` instead.", }, - { - name: "next/navigation", - importNames: [ - "redirect", - "permanentRedirect", - "useRouter", - "usePathname", - ], - message: "Please import from `vitnode-frontend/navigation` instead.", - }, - { - name: "next/router", - importNames: ["useRouter"], - message: - "This import is from Page router. Please import from `vitnode-frontend/navigation` instead.", - }, ], }, }, diff --git a/packages/config/eslint.react.config.mjs b/packages/config/eslint.react.config.mjs new file mode 100644 index 000000000..330937bad --- /dev/null +++ b/packages/config/eslint.react.config.mjs @@ -0,0 +1,67 @@ +// @ts-check + +import eslintReact from "@eslint-react/eslint-plugin"; +import reactPlugin from "eslint-plugin-react"; +import hooksPlugin from "eslint-plugin-react-hooks"; +import reactYouMightNotNeedAnEffect from "eslint-plugin-react-you-might-not-need-an-effect"; + +export default [ + reactYouMightNotNeedAnEffect.configs.recommended, + eslintReact.configs.recommended, + reactPlugin.configs.flat.recommended, + { + files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], + settings: { + react: { + version: "detect", + }, + }, + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + }, + { + plugins: { + "react-hooks": hooksPlugin, + }, + rules: { + "react/react-in-jsx-scope": "off", + ...hooksPlugin.configs.recommended.rules, + }, + }, + { files: ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"] }, + { + rules: { + "react-hooks/exhaustive-deps": "off", + "@eslint-react/no-context-provider": "off", + "@eslint-react/no-unstable-default-props": "off", + "no-restricted-imports": [ + "error", + { + name: "next/link", + message: "Please import from `vitnode-frontend/navigation` instead.", + }, + { + name: "next/navigation", + importNames: [ + "redirect", + "permanentRedirect", + "useRouter", + "usePathname", + ], + message: "Please import from `vitnode-frontend/navigation` instead.", + }, + { + name: "next/router", + importNames: ["useRouter"], + message: + "This import is from Page router. Please import from `vitnode-frontend/navigation` instead.", + }, + ], + }, + }, +]; diff --git a/packages/config/package.json b/packages/config/package.json index 144bbc12e..99e637469 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -23,6 +23,10 @@ "import": "./eslint.config.mjs", "default": "./eslint.config.mjs" }, + "./eslint.react": { + "import": "./eslint.react.config.mjs", + "default": "./eslint.react.config.mjs" + }, "./tsconfig": { "import": "./tsconfig.json", "default": "./tsconfig.json" diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/eslint/eslint.config.mjs b/packages/create-vitnode-app/copy-of-vitnode-app/eslint/eslint.config.mjs index 8c0f6171d..b1ce26beb 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/eslint/eslint.config.mjs +++ b/packages/create-vitnode-app/copy-of-vitnode-app/eslint/eslint.config.mjs @@ -1,4 +1,5 @@ import eslintVitNode from "@vitnode/config/eslint"; +import eslintVitNodeReact from "@vitnode/config/eslint.react"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; @@ -6,6 +7,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default [ ...eslintVitNode, + ...eslintVitNodeReact, { languageOptions: { parserOptions: { diff --git a/packages/node-cron/.npmignore b/packages/node-cron/.npmignore new file mode 100644 index 000000000..10004b73c --- /dev/null +++ b/packages/node-cron/.npmignore @@ -0,0 +1,6 @@ +/.turbo +/src +/node_modules +/tsconfig.json +/.swcrc +/eslint.config.mjs \ No newline at end of file diff --git a/packages/node-cron/.swcrc b/packages/node-cron/.swcrc new file mode 100644 index 000000000..eba97079c --- /dev/null +++ b/packages/node-cron/.swcrc @@ -0,0 +1,25 @@ +{ + "$schema": "https://swc.rs/schema.json", + "minify": true, + "jsc": { + "baseUrl": "./", + "target": "esnext", + "paths": { + "@/*": ["./src/*"] + }, + "parser": { + "syntax": "typescript", + "tsx": true + }, + "transform": { + "react": { + "runtime": "automatic" + } + } + }, + "module": { + "type": "nodenext", + "strict": true, + "resolveFully": true + } +} diff --git a/packages/node-cron/README.md b/packages/node-cron/README.md new file mode 100644 index 000000000..a68ff4bab --- /dev/null +++ b/packages/node-cron/README.md @@ -0,0 +1,20 @@ +# (VitNode) Node-cron Adapter + +This package provides the Node-cron adapter for VitNode, enabling cron job scheduling and management within your VitNode applications. + +

+
+ + + + + VitNode Logo + + +
+
+

+ +| Cloud | Self-Hosted | Links | Documentation | +| ---------------- | ------------ | ------------------------------------------------------ | --------------------------------------------------- | +| ❌ Not Supported | ✅ Supported | [NPM Package](https://www.npmjs.com/package/node-cron) | [Docs](https://vitnode.com/docs/dev/cron/node-cron) | diff --git a/packages/node-cron/eslint.config.mjs b/packages/node-cron/eslint.config.mjs new file mode 100644 index 000000000..8c0f6171d --- /dev/null +++ b/packages/node-cron/eslint.config.mjs @@ -0,0 +1,17 @@ +import eslintVitNode from "@vitnode/config/eslint"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default [ + ...eslintVitNode, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + }, + }, + }, +]; diff --git a/packages/node-cron/package.json b/packages/node-cron/package.json new file mode 100644 index 000000000..928766c0c --- /dev/null +++ b/packages/node-cron/package.json @@ -0,0 +1,46 @@ +{ + "name": "@vitnode/node-cron", + "version": "1.2.0-canary.60", + "description": "Node-cron adapter for VitNode, enabling cron job scheduling and management.", + "author": "VitNode Team", + "license": "MIT", + "homepage": "https://vitnode.com", + "repository": { + "type": "git", + "url": "git+https://github.com/aXenDeveloper/vitnode.git", + "directory": "packages/node-cron" + }, + "keywords": [ + "vitnode", + "node-cron", + "cron", + "scheduler" + ], + "type": "module", + "exports": { + ".": { + "import": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "scripts": { + "build:plugins": "tsc && swc src -d dist --config-file .swcrc && tsc-alias -p tsconfig.json", + "dev:plugins": "concurrently \"tsc -w --preserveWatchOutput\" \"swc src -d dist --config-file .swcrc -w\" \"tsc-alias -w\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "dependencies": { + "node-cron": "^4.2.1" + }, + "devDependencies": { + "@swc/cli": "^0.7.9", + "@swc/core": "^1.15.1", + "@vitnode/config": "workspace:*", + "@vitnode/core": "workspace:*", + "concurrently": "^9.2.1", + "eslint": "^9.39.1", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + } +} diff --git a/packages/vitnode/src/api/adapters/cron/node-cron.adapter.ts b/packages/node-cron/src/index.ts similarity index 73% rename from packages/vitnode/src/api/adapters/cron/node-cron.adapter.ts rename to packages/node-cron/src/index.ts index de2606ecb..d51d24d85 100644 --- a/packages/vitnode/src/api/adapters/cron/node-cron.adapter.ts +++ b/packages/node-cron/src/index.ts @@ -1,7 +1,6 @@ +import { type CronAdapter, handleCronJobs } from "@vitnode/core/api/lib/cron"; import { schedule } from "node-cron"; -import { type CronAdapter, handleCronJobs } from "@/api/lib/cron"; - export const NodeCronAdapter = (): CronAdapter => { return { schedule() { diff --git a/packages/node-cron/tsconfig.json b/packages/node-cron/tsconfig.json new file mode 100644 index 000000000..7593a944f --- /dev/null +++ b/packages/node-cron/tsconfig.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@vitnode/config/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "esnext", + "moduleResolution": "bundler", + "rootDir": "./", + "outDir": "./dist", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules"], + "include": ["src"] +} diff --git a/packages/nodemailer/.npmignore b/packages/nodemailer/.npmignore new file mode 100644 index 000000000..10004b73c --- /dev/null +++ b/packages/nodemailer/.npmignore @@ -0,0 +1,6 @@ +/.turbo +/src +/node_modules +/tsconfig.json +/.swcrc +/eslint.config.mjs \ No newline at end of file diff --git a/packages/nodemailer/.swcrc b/packages/nodemailer/.swcrc new file mode 100644 index 000000000..eba97079c --- /dev/null +++ b/packages/nodemailer/.swcrc @@ -0,0 +1,25 @@ +{ + "$schema": "https://swc.rs/schema.json", + "minify": true, + "jsc": { + "baseUrl": "./", + "target": "esnext", + "paths": { + "@/*": ["./src/*"] + }, + "parser": { + "syntax": "typescript", + "tsx": true + }, + "transform": { + "react": { + "runtime": "automatic" + } + } + }, + "module": { + "type": "nodenext", + "strict": true, + "resolveFully": true + } +} diff --git a/packages/nodemailer/README.md b/packages/nodemailer/README.md new file mode 100644 index 000000000..57add38dc --- /dev/null +++ b/packages/nodemailer/README.md @@ -0,0 +1,20 @@ +# (VitNode) Nodemailer (SMTP) Adapter + +This package provides a Nodemailer email adapter for VitNode, enabling email sending capabilities using SMTP. + +

+
+ + + + + VitNode Logo + + +
+
+

+ +| Cloud | Self-Hosted | Links | Documentation | +| ---------------- | ------------ | ------------------------------------------------------- | ----------------------------------------------------- | +| ❌ Not Supported | ✅ Supported | [NPM Package](https://www.npmjs.com/package/nodemailer) | [Docs](https://vitnode.com/docs/dev/email/nodemailer) | diff --git a/packages/nodemailer/eslint.config.mjs b/packages/nodemailer/eslint.config.mjs new file mode 100644 index 000000000..8c0f6171d --- /dev/null +++ b/packages/nodemailer/eslint.config.mjs @@ -0,0 +1,17 @@ +import eslintVitNode from "@vitnode/config/eslint"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default [ + ...eslintVitNode, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + }, + }, + }, +]; diff --git a/packages/nodemailer/package.json b/packages/nodemailer/package.json new file mode 100644 index 000000000..fa373c40a --- /dev/null +++ b/packages/nodemailer/package.json @@ -0,0 +1,46 @@ +{ + "name": "@vitnode/nodemailer", + "version": "1.2.0-canary.60", + "description": "Nodemailer integration package for VitNode, enabling email functionalities.", + "author": "VitNode Team", + "license": "MIT", + "homepage": "https://vitnode.com", + "repository": { + "type": "git", + "url": "git+https://github.com/aXenDeveloper/vitnode.git", + "directory": "packages/nodemailer" + }, + "keywords": [ + "vitnode", + "nodemailer", + "smtp" + ], + "type": "module", + "exports": { + ".": { + "import": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "scripts": { + "build:plugins": "tsc && swc src -d dist --config-file .swcrc && tsc-alias -p tsconfig.json", + "dev:plugins": "concurrently \"tsc -w --preserveWatchOutput\" \"swc src -d dist --config-file .swcrc -w\" \"tsc-alias -w\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "dependencies": { + "nodemailer": "^7.0.10" + }, + "devDependencies": { + "@swc/cli": "^0.7.9", + "@swc/core": "^1.15.1", + "@types/nodemailer": "^7.0.3", + "@vitnode/config": "workspace:*", + "@vitnode/core": "workspace:*", + "concurrently": "^9.2.1", + "eslint": "^9.39.1", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + } +} diff --git a/packages/vitnode/src/api/adapters/email/nodemailer.ts b/packages/nodemailer/src/index.ts similarity index 93% rename from packages/vitnode/src/api/adapters/email/nodemailer.ts rename to packages/nodemailer/src/index.ts index 7f00ecfec..1a218d18e 100644 --- a/packages/vitnode/src/api/adapters/email/nodemailer.ts +++ b/packages/nodemailer/src/index.ts @@ -1,6 +1,6 @@ -import { createTransport } from "nodemailer"; +import type { EmailApiPlugin } from "@vitnode/core/api/models/email"; -import type { EmailApiPlugin } from "@/api/models/email"; +import { createTransport } from "nodemailer"; export const NodemailerEmailAdapter = ({ host = "", diff --git a/packages/nodemailer/tsconfig.json b/packages/nodemailer/tsconfig.json new file mode 100644 index 000000000..7593a944f --- /dev/null +++ b/packages/nodemailer/tsconfig.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@vitnode/config/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "esnext", + "moduleResolution": "bundler", + "rootDir": "./", + "outDir": "./dist", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules"], + "include": ["src"] +} diff --git a/packages/resend/.npmignore b/packages/resend/.npmignore new file mode 100644 index 000000000..10004b73c --- /dev/null +++ b/packages/resend/.npmignore @@ -0,0 +1,6 @@ +/.turbo +/src +/node_modules +/tsconfig.json +/.swcrc +/eslint.config.mjs \ No newline at end of file diff --git a/packages/resend/.swcrc b/packages/resend/.swcrc new file mode 100644 index 000000000..eba97079c --- /dev/null +++ b/packages/resend/.swcrc @@ -0,0 +1,25 @@ +{ + "$schema": "https://swc.rs/schema.json", + "minify": true, + "jsc": { + "baseUrl": "./", + "target": "esnext", + "paths": { + "@/*": ["./src/*"] + }, + "parser": { + "syntax": "typescript", + "tsx": true + }, + "transform": { + "react": { + "runtime": "automatic" + } + } + }, + "module": { + "type": "nodenext", + "strict": true, + "resolveFully": true + } +} diff --git a/packages/resend/README.md b/packages/resend/README.md new file mode 100644 index 000000000..8fdbcee2c --- /dev/null +++ b/packages/resend/README.md @@ -0,0 +1,20 @@ +# (VitNode) Resend Adapter + +This package provides an adapter for integrating Resend email services into VitNode applications, enabling seamless email sending capabilities. + +

+
+ + + + + VitNode Logo + + +
+
+

+ +| Cloud | Self-Hosted | Links | Documentation | +| ------------ | ------------ | --------------------------------------------------- | ------------------------------------------------- | +| ✅ Supported | ✅ Supported | [NPM Package](https://www.npmjs.com/package/resend) | [Docs](https://vitnode.com/docs/dev/email/resend) | diff --git a/packages/resend/eslint.config.mjs b/packages/resend/eslint.config.mjs new file mode 100644 index 000000000..8c0f6171d --- /dev/null +++ b/packages/resend/eslint.config.mjs @@ -0,0 +1,17 @@ +import eslintVitNode from "@vitnode/config/eslint"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default [ + ...eslintVitNode, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + }, + }, + }, +]; diff --git a/packages/resend/package.json b/packages/resend/package.json new file mode 100644 index 000000000..1a1e1d55f --- /dev/null +++ b/packages/resend/package.json @@ -0,0 +1,44 @@ +{ + "name": "@vitnode/resend", + "version": "1.2.0-canary.60", + "description": "Resend adapter for VitNode, enabling email sending capabilities through the Resend service.", + "author": "VitNode Team", + "license": "MIT", + "homepage": "https://vitnode.com", + "repository": { + "type": "git", + "url": "git+https://github.com/aXenDeveloper/vitnode.git", + "directory": "packages/resend" + }, + "keywords": [ + "vitnode", + "resend" + ], + "type": "module", + "exports": { + ".": { + "import": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "scripts": { + "build:plugins": "tsc && swc src -d dist --config-file .swcrc && tsc-alias -p tsconfig.json", + "dev:plugins": "concurrently \"tsc -w --preserveWatchOutput\" \"swc src -d dist --config-file .swcrc -w\" \"tsc-alias -w\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "dependencies": { + "resend": "^6.4.2" + }, + "devDependencies": { + "@swc/cli": "^0.7.9", + "@swc/core": "^1.15.1", + "@vitnode/config": "workspace:*", + "@vitnode/core": "workspace:*", + "concurrently": "^9.2.1", + "eslint": "^9.39.1", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + } +} diff --git a/packages/vitnode/src/api/adapters/email/resend.ts b/packages/resend/src/index.ts similarity index 90% rename from packages/vitnode/src/api/adapters/email/resend.ts rename to packages/resend/src/index.ts index 8c2f3cf3a..9ecadf5d6 100644 --- a/packages/vitnode/src/api/adapters/email/resend.ts +++ b/packages/resend/src/index.ts @@ -1,6 +1,6 @@ -import { Resend } from "resend"; +import type { EmailApiPlugin } from "@vitnode/core/api/models/email"; -import type { EmailApiPlugin } from "@/api/models/email"; +import { Resend } from "resend"; export const ResendEmailAdapter = ({ apiKey, diff --git a/packages/resend/tsconfig.json b/packages/resend/tsconfig.json new file mode 100644 index 000000000..7593a944f --- /dev/null +++ b/packages/resend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@vitnode/config/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "esnext", + "moduleResolution": "bundler", + "rootDir": "./", + "outDir": "./dist", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules"], + "include": ["src"] +} diff --git a/packages/vitnode/.swcrc b/packages/vitnode/.swcrc index c9903f975..eba97079c 100644 --- a/packages/vitnode/.swcrc +++ b/packages/vitnode/.swcrc @@ -1,6 +1,6 @@ { "$schema": "https://swc.rs/schema.json", - "minify": false, + "minify": true, "jsc": { "baseUrl": "./", "target": "esnext", diff --git a/packages/vitnode/eslint.config.mjs b/packages/vitnode/eslint.config.mjs index 8c0f6171d..b1ce26beb 100644 --- a/packages/vitnode/eslint.config.mjs +++ b/packages/vitnode/eslint.config.mjs @@ -1,4 +1,5 @@ import eslintVitNode from "@vitnode/config/eslint"; +import eslintVitNodeReact from "@vitnode/config/eslint.react"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; @@ -6,6 +7,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default [ ...eslintVitNode, + ...eslintVitNodeReact, { languageOptions: { parserOptions: { diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 77bad6fd8..f8419f925 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -46,7 +46,6 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@types/node": "^24.10.0", - "@types/nodemailer": "^7.0.3", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", @@ -63,7 +62,6 @@ "lucide-react": "^0.553.0", "next": "^16.0.1", "next-intl": "^4.5.0", - "node-cron": "^4.2.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-email": "^5.0.1", @@ -126,12 +124,10 @@ "input-otp": "^1.4.2", "motion": "^12.23.24", "next-themes": "^0.4.6", - "nodemailer": "^7.0.10", "postgres": "^3.4.7", "radix-ui": "^1.4.3", "rate-limiter-flexible": "^8.2.0", "react-scan": "^0.4.3", - "resend": "^6.4.2", "tailwind-merge": "^3.4.0", "use-debounce": "^10.0.6", "use-intl": "^4.5.0", diff --git a/packages/vitnode/src/api/modules/cron/helpers/process-cron-jobs.ts b/packages/vitnode/src/api/modules/cron/helpers/process-cron-jobs.ts index 6d2ef8ffd..c7916f627 100644 --- a/packages/vitnode/src/api/modules/cron/helpers/process-cron-jobs.ts +++ b/packages/vitnode/src/api/modules/cron/helpers/process-cron-jobs.ts @@ -1,12 +1,12 @@ import type { drizzle } from "drizzle-orm/postgres-js"; import { eq, inArray } from "drizzle-orm"; -import { validate } from "node-cron"; import type { CronJobConfig } from "@/api/lib/cron"; import { core_cron } from "@/database/cron"; import { shouldCronJobRun } from "@/lib/api/should-cron-job-run"; +import { validateCronSchedule } from "@/lib/api/validate-cron-schedule"; interface CronJobFromDb { createdAt: Date; @@ -82,7 +82,7 @@ export function processCronJobs( ); for (const job of cronJobs) { - if (!validate(job.schedule)) { + if (!validateCronSchedule(job.schedule)) { // eslint-disable-next-line no-console console.warn( `\x1b[34m[VitNode]\x1b[0m \x1b[33mInvalid cron schedule for job "${job.pluginId}:${job.module}:${job.name}"\x1b[0m: ${job.schedule}`, diff --git a/packages/vitnode/src/lib/api/validate-cron-schedule.test.ts b/packages/vitnode/src/lib/api/validate-cron-schedule.test.ts new file mode 100644 index 000000000..d944b6d73 --- /dev/null +++ b/packages/vitnode/src/lib/api/validate-cron-schedule.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; + +import { validateCronSchedule } from "./validate-cron-schedule"; + +describe("validateCronSchedule", () => { + describe("valid 5-field cron expressions", () => { + it("should validate wildcard expression", () => { + expect(validateCronSchedule("* * * * *")).toBe(true); + }); + + it("should validate specific time expressions", () => { + expect(validateCronSchedule("0 0 * * *")).toBe(true); // Daily at midnight + expect(validateCronSchedule("30 14 * * 1")).toBe(true); // Every Monday at 14:30 + expect(validateCronSchedule("0 0 1 * *")).toBe(true); // First day of month at midnight + expect(validateCronSchedule("0 0 1 1 *")).toBe(true); // January 1st at midnight + expect(validateCronSchedule("15 10 * * 5")).toBe(true); // Every Friday at 10:15 + }); + + it("should validate step expressions", () => { + expect(validateCronSchedule("*/5 * * * *")).toBe(true); // Every 5 minutes + expect(validateCronSchedule("*/15 * * * *")).toBe(true); // Every 15 minutes + expect(validateCronSchedule("0 */2 * * *")).toBe(true); // Every 2 hours + expect(validateCronSchedule("0 0 */3 * *")).toBe(true); // Every 3 days + expect(validateCronSchedule("0-30/5 * * * *")).toBe(true); // Every 5 minutes from 0 to 30 + }); + + it("should validate range expressions", () => { + expect(validateCronSchedule("0 9-17 * * *")).toBe(true); // Every hour from 9am to 5pm + expect(validateCronSchedule("0 0 1-15 * *")).toBe(true); // First 15 days of month + expect(validateCronSchedule("0 0 * 1-6 *")).toBe(true); // First 6 months + expect(validateCronSchedule("0 0 * * 1-5")).toBe(true); // Monday to Friday + }); + + it("should validate list expressions", () => { + expect(validateCronSchedule("0 0,12 * * *")).toBe(true); // At midnight and noon + expect(validateCronSchedule("0 0 1,15 * *")).toBe(true); // 1st and 15th of month + expect(validateCronSchedule("0 0 * * 1,3,5")).toBe(true); // Monday, Wednesday, Friday + expect(validateCronSchedule("0 9,12,15 * * *")).toBe(true); // At 9am, noon, 3pm + }); + + it("should validate complex expressions", () => { + expect(validateCronSchedule("0-30/5 9-17 * * 1-5")).toBe(true); // Every 5 minutes from 0-30, 9am-5pm, Monday-Friday + expect(validateCronSchedule("0 0,12 1,15 * *")).toBe(true); // Midnight and noon on 1st and 15th + expect(validateCronSchedule("*/10 */2 * * *")).toBe(true); // Every 10 minutes, every 2 hours + }); + + it("should validate weekday 0 and 7 (both Sunday)", () => { + expect(validateCronSchedule("0 0 * * 0")).toBe(true); // Sunday + expect(validateCronSchedule("0 0 * * 7")).toBe(true); // Sunday (alternative) + }); + + it("should validate edge cases", () => { + expect(validateCronSchedule("59 23 31 12 7")).toBe(true); // Max values + expect(validateCronSchedule("0 0 1 1 0")).toBe(true); // Min values (except minute/hour) + }); + }); + + describe("valid 6-field cron expressions (with seconds)", () => { + it("should validate wildcard expression with seconds", () => { + expect(validateCronSchedule("* * * * * *")).toBe(true); + }); + + it("should validate specific time expressions with seconds", () => { + expect(validateCronSchedule("0 0 0 * * *")).toBe(true); // Daily at midnight + expect(validateCronSchedule("30 30 14 * * 1")).toBe(true); // Every Monday at 14:30:30 + expect(validateCronSchedule("0 0 0 1 * *")).toBe(true); // First day of month at midnight + }); + + it("should validate step expressions with seconds", () => { + expect(validateCronSchedule("*/5 * * * * *")).toBe(true); // Every 5 seconds + expect(validateCronSchedule("0 */5 * * * *")).toBe(true); // Every 5 minutes + expect(validateCronSchedule("*/30 0 * * * *")).toBe(true); // Every 30 seconds at minute 0 + }); + + it("should validate range expressions with seconds", () => { + expect(validateCronSchedule("0-30 0 9-17 * * *")).toBe(true); // Seconds 0-30, minute 0, 9am-5pm + }); + + it("should validate list expressions with seconds", () => { + expect(validateCronSchedule("0,30 0 0,12 * * *")).toBe(true); // At 0 and 30 seconds, midnight and noon + }); + }); + + describe("invalid cron expressions", () => { + it("should reject empty or non-string input", () => { + expect(validateCronSchedule("")).toBe(false); + expect(validateCronSchedule(" ")).toBe(false); + // @ts-expect-error - Testing invalid input + expect(validateCronSchedule(null)).toBe(false); + // @ts-expect-error - Testing invalid input + expect(validateCronSchedule(undefined)).toBe(false); + // @ts-expect-error - Testing invalid input + expect(validateCronSchedule(123)).toBe(false); + }); + + it("should reject wrong number of fields", () => { + expect(validateCronSchedule("* * *")).toBe(false); // Too few + expect(validateCronSchedule("* * * *")).toBe(false); // Too few + expect(validateCronSchedule("* * * * * * *")).toBe(false); // Too many + expect(validateCronSchedule("* * * * * * * *")).toBe(false); // Too many + }); + + it("should reject invalid characters", () => { + expect(validateCronSchedule("a * * * *")).toBe(false); + expect(validateCronSchedule("* b * * *")).toBe(false); + expect(validateCronSchedule("* * c * *")).toBe(false); + expect(validateCronSchedule("@ # $ % ^")).toBe(false); + expect(validateCronSchedule("invalid cron")).toBe(false); + }); + + it("should reject out-of-range values", () => { + expect(validateCronSchedule("60 * * * *")).toBe(false); // Minute > 59 + expect(validateCronSchedule("* 24 * * *")).toBe(false); // Hour > 23 + expect(validateCronSchedule("* * 32 * *")).toBe(false); // Day > 31 + expect(validateCronSchedule("* * 0 * *")).toBe(false); // Day < 1 + expect(validateCronSchedule("* * * 13 *")).toBe(false); // Month > 12 + expect(validateCronSchedule("* * * 0 *")).toBe(false); // Month < 1 + expect(validateCronSchedule("* * * * 8")).toBe(false); // Weekday > 7 + expect(validateCronSchedule("-1 * * * *")).toBe(false); // Negative minute + }); + + it("should reject out-of-range values in 6-field format", () => { + expect(validateCronSchedule("60 * * * * *")).toBe(false); // Second > 59 + expect(validateCronSchedule("-1 * * * * *")).toBe(false); // Negative second + }); + + it("should reject invalid ranges", () => { + expect(validateCronSchedule("10-5 * * * *")).toBe(false); // Start > end + expect(validateCronSchedule("* 20-10 * * *")).toBe(false); // Start > end + expect(validateCronSchedule("* * 60-70 * *")).toBe(false); // Out of range + expect(validateCronSchedule("0-60 * * * *")).toBe(false); // End out of range + }); + + it("should reject invalid steps", () => { + expect(validateCronSchedule("*/0 * * * *")).toBe(false); // Step of 0 + expect(validateCronSchedule("*/-1 * * * *")).toBe(false); // Negative step + expect(validateCronSchedule("*/60 * * * *")).toBe(false); // Step > max + expect(validateCronSchedule("*/abc * * * *")).toBe(false); // Non-numeric step + expect(validateCronSchedule("* */25 * * *")).toBe(false); // Step > max for hour + }); + + it("should reject invalid lists", () => { + expect(validateCronSchedule("0,60 * * * *")).toBe(false); // Out of range in list + expect(validateCronSchedule("* 0,24 * * *")).toBe(false); // Out of range in list + expect(validateCronSchedule("a,b,c * * * *")).toBe(false); // Non-numeric list + expect(validateCronSchedule(",, * * * *")).toBe(false); // Empty list items + }); + + it("should reject malformed expressions", () => { + expect(validateCronSchedule("1--5 * * * *")).toBe(false); // Double dash + expect(validateCronSchedule("1//5 * * * *")).toBe(false); // Double slash + expect(validateCronSchedule("1- * * * *")).toBe(false); // Incomplete range + expect(validateCronSchedule("-5 * * * *")).toBe(false); // Invalid start + expect(validateCronSchedule("1/ * * * *")).toBe(false); // Incomplete step + expect(validateCronSchedule("/5 * * * *")).toBe(false); // Missing base for step + }); + }); + + describe("edge cases", () => { + it("should handle extra whitespace", () => { + expect(validateCronSchedule(" * * * * * ")).toBe(true); + expect(validateCronSchedule("0 0 * * *")).toBe(true); + }); + + it("should reject mixed valid and invalid fields", () => { + expect(validateCronSchedule("0 0 * * invalid")).toBe(false); + expect(validateCronSchedule("0 25 * * *")).toBe(false); // Invalid hour + expect(validateCronSchedule("* * * * * 60")).toBe(false); // Invalid second in 6-field + }); + }); +}); diff --git a/packages/vitnode/src/lib/api/validate-cron-schedule.ts b/packages/vitnode/src/lib/api/validate-cron-schedule.ts new file mode 100644 index 000000000..d0655bb5c --- /dev/null +++ b/packages/vitnode/src/lib/api/validate-cron-schedule.ts @@ -0,0 +1,128 @@ +/** + * Validates a cron schedule expression + * Supports standard cron format: minute hour day month weekday + * Also supports extended format with seconds (6 fields): second minute hour day month weekday + * + * @param schedule - The cron schedule string to validate + * @returns true if the schedule is valid, false otherwise + * + * @example + * ```typescript + * validateCronSchedule("0 0 * * *") // true - runs at midnight every day + * validateCronSchedule("*\/5 * * * *") // true - runs every 5 minutes + * validateCronSchedule("0 0 1 * *") // true - runs at midnight on the first day of each month + * validateCronSchedule("invalid") // false + * ``` + */ +export function validateCronSchedule(schedule: string): boolean { + if (!schedule || typeof schedule !== "string") { + return false; + } + + const trimmedSchedule = schedule.trim(); + if (!trimmedSchedule) { + return false; + } + + const parts = trimmedSchedule.split(/\s+/); + + // Support both 5-field (minute hour day month weekday) and 6-field (second minute hour day month weekday) formats + if (parts.length !== 5 && parts.length !== 6) { + return false; + } + + // Define field configurations + const fieldConfigs = + parts.length === 6 + ? [ + { name: "second", min: 0, max: 59 }, + { name: "minute", min: 0, max: 59 }, + { name: "hour", min: 0, max: 23 }, + { name: "day", min: 1, max: 31 }, + { name: "month", min: 1, max: 12 }, + { name: "weekday", min: 0, max: 7 }, // 0 and 7 both represent Sunday + ] + : [ + { name: "minute", min: 0, max: 59 }, + { name: "hour", min: 0, max: 23 }, + { name: "day", min: 1, max: 31 }, + { name: "month", min: 1, max: 12 }, + { name: "weekday", min: 0, max: 7 }, // 0 and 7 both represent Sunday + ]; + + // Validate each field + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const config = fieldConfigs[i]; + + if (!validateCronField(part, config.min, config.max)) { + return false; + } + } + + return true; +} + +/** + * Validates a single cron field + * Supports: asterisk, numbers, ranges (1-5), steps (star/5, 1-10/2), and lists (1,2,3) + */ +function validateCronField(field: string, min: number, max: number): boolean { + if (field === "*") { + return true; + } + + // Handle lists (e.g., "1,2,3,5") + if (field.includes(",")) { + const listItems = field.split(","); + + return listItems.every(item => validateCronField(item.trim(), min, max)); + } + + // Handle steps (e.g., "*/5" or "1-10/2") + if (field.includes("/")) { + const [range, step] = field.split("/"); + const stepNum = parseInt(step, 10); + + if (isNaN(stepNum) || stepNum <= 0 || stepNum > max) { + return false; + } + + // If range is "*", it's valid + if (range === "*") { + return true; + } + + // Otherwise, validate the range part + return validateCronField(range, min, max); + } + + // Handle ranges (e.g., "1-5") + if (field.includes("-")) { + const [start, end] = field.split("-"); + const startNum = parseInt(start, 10); + const endNum = parseInt(end, 10); + + if ( + isNaN(startNum) || + isNaN(endNum) || + startNum < min || + startNum > max || + endNum < min || + endNum > max || + startNum > endNum + ) { + return false; + } + + return true; + } + + // Handle single numbers + const num = parseInt(field, 10); + if (isNaN(num) || num < min || num > max) { + return false; + } + + return true; +} diff --git a/packages/vitnode/tsconfig.json b/packages/vitnode/tsconfig.json index ad865484f..de0976d61 100644 --- a/packages/vitnode/tsconfig.json +++ b/packages/vitnode/tsconfig.json @@ -8,7 +8,7 @@ "rootDir": "./", "outDir": "./dist", "jsx": "react-jsx", - "emitDeclarationOnly": true, + "emitDeclarationOnly": false, "declaration": true, "declarationMap": true, "plugins": [ diff --git a/packages/vitnode/vitest.config.ts b/packages/vitnode/vitest.config.ts index 3a3574d48..237c1132e 100644 --- a/packages/vitnode/vitest.config.ts +++ b/packages/vitnode/vitest.config.ts @@ -9,6 +9,20 @@ export default defineConfig({ globals: true, environment: "jsdom", setupFiles: ["./src/tests/setup.ts"], + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/.next/**", + "**/.turbo/**", + "**/coverage/**", + "**/src/tests/**", // Assuming setup files aren't tests + "**/src/emails/**", + "**/config/**", + "**/scripts/**", + "**/*.config.*", + "**/*.d.ts", + ], coverage: { provider: "v8", reporter: ["text", "json", "html"], diff --git a/plugins/blog/eslint.config.mjs b/plugins/blog/eslint.config.mjs index 8c0f6171d..b1ce26beb 100644 --- a/plugins/blog/eslint.config.mjs +++ b/plugins/blog/eslint.config.mjs @@ -1,4 +1,5 @@ import eslintVitNode from "@vitnode/config/eslint"; +import eslintVitNodeReact from "@vitnode/config/eslint.react"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; @@ -6,6 +7,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default [ ...eslintVitNode, + ...eslintVitNodeReact, { languageOptions: { parserOptions: { diff --git a/plugins/blog/src/api/modules/categories/test.route.ts b/plugins/blog/src/api/modules/categories/test.route.ts index d3260901b..be8151267 100644 --- a/plugins/blog/src/api/modules/categories/test.route.ts +++ b/plugins/blog/src/api/modules/categories/test.route.ts @@ -32,7 +32,7 @@ export const testRoute = buildRoute({ }, handler: async c => { const user = await new UserModel().getUserById({ - id: 3, + id: 2, c, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9620f3b09..5dd64f27d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@vitnode/config': specifier: workspace:* version: link:../../packages/config + '@vitnode/nodemailer': + specifier: workspace:* + version: link:../../packages/nodemailer dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -196,6 +199,15 @@ importers: '@vitnode/config': specifier: workspace:* version: link:../../packages/config + '@vitnode/node-cron': + specifier: workspace:* + version: link:../../packages/node-cron + '@vitnode/nodemailer': + specifier: workspace:* + version: link:../../packages/nodemailer + '@vitnode/resend': + specifier: workspace:* + version: link:../../packages/resend babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -313,6 +325,102 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/node-cron: + dependencies: + node-cron: + specifier: ^4.2.1 + version: 4.2.1 + devDependencies: + '@swc/cli': + specifier: ^0.7.9 + version: 0.7.9(@swc/core@1.15.1)(chokidar@4.0.3) + '@swc/core': + specifier: ^1.15.1 + version: 1.15.1 + '@vitnode/config': + specifier: workspace:* + version: link:../config + '@vitnode/core': + specifier: workspace:* + version: link:../vitnode + concurrently: + specifier: ^9.2.1 + version: 9.2.1 + eslint: + specifier: ^9.39.1 + version: 9.39.1(jiti@2.6.1) + tsc-alias: + specifier: ^1.8.16 + version: 1.8.16 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + packages/nodemailer: + dependencies: + nodemailer: + specifier: ^7.0.10 + version: 7.0.10 + devDependencies: + '@swc/cli': + specifier: ^0.7.9 + version: 0.7.9(@swc/core@1.15.1)(chokidar@4.0.3) + '@swc/core': + specifier: ^1.15.1 + version: 1.15.1 + '@types/nodemailer': + specifier: ^7.0.3 + version: 7.0.3 + '@vitnode/config': + specifier: workspace:* + version: link:../config + '@vitnode/core': + specifier: workspace:* + version: link:../vitnode + concurrently: + specifier: ^9.2.1 + version: 9.2.1 + eslint: + specifier: ^9.39.1 + version: 9.39.1(jiti@2.6.1) + tsc-alias: + specifier: ^1.8.16 + version: 1.8.16 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + packages/resend: + dependencies: + resend: + specifier: ^6.4.2 + version: 6.4.2(@react-email/render@2.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) + devDependencies: + '@swc/cli': + specifier: ^0.7.9 + version: 0.7.9(@swc/core@1.15.1)(chokidar@4.0.3) + '@swc/core': + specifier: ^1.15.1 + version: 1.15.1 + '@vitnode/config': + specifier: workspace:* + version: link:../config + '@vitnode/core': + specifier: workspace:* + version: link:../vitnode + concurrently: + specifier: ^9.2.1 + version: 9.2.1 + eslint: + specifier: ^9.39.1 + version: 9.39.1(jiti@2.6.1) + tsc-alias: + specifier: ^1.8.16 + version: 1.8.16 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/vitnode: dependencies: '@dnd-kit/core': @@ -360,9 +468,6 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - nodemailer: - specifier: ^7.0.10 - version: 7.0.10 postgres: specifier: ^3.4.7 version: 3.4.7 @@ -375,9 +480,6 @@ importers: react-scan: specifier: ^0.4.3 version: 0.4.3(@types/react@19.2.2)(next@16.0.1(@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-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.53.1) - resend: - specifier: ^6.4.2 - version: 6.4.2(@react-email/render@2.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -418,9 +520,6 @@ importers: '@types/node': specifier: ^24.10.0 version: 24.10.0 - '@types/nodemailer': - specifier: ^7.0.3 - version: 7.0.3 '@types/react': specifier: ^19.2.2 version: 19.2.2 @@ -469,9 +568,6 @@ importers: next-intl: specifier: ^4.5.0 version: 4.5.0(next@16.0.1(@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 react: specifier: ^19.2.0 version: 19.2.0