From ee96f34219089e9f5d54f0594ec11199466e52c2 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 11 Nov 2025 12:55:32 +0100 Subject: [PATCH 1/3] refactor(email): Create nodemailer package --- .github/workflows/bump_publish.yml | 6 +- apps/api/package.json | 1 + apps/api/src/vitnode.api.config.ts | 2 +- .../content/docs/dev/email/nodemailer.mdx | 71 +++++++++++++++++++ apps/docs/content/docs/dev/email/smtp.mdx | 13 ---- apps/docs/eslint.config.mjs | 2 + apps/docs/package.json | 1 + apps/docs/src/vitnode.api.config.ts | 3 +- packages/config/eslint.config.mjs | 60 ---------------- packages/config/eslint.react.config.mjs | 67 +++++++++++++++++ packages/config/package.json | 4 ++ .../eslint/eslint.config.mjs | 2 + packages/nodemailer/.swcrc | 25 +++++++ packages/nodemailer/README.md | 20 ++++++ packages/nodemailer/eslint.config.mjs | 17 +++++ packages/nodemailer/package.json | 46 ++++++++++++ .../nodemailer.ts => nodemailer/src/index.ts} | 4 +- packages/nodemailer/tsconfig.json | 19 +++++ packages/vitnode/.swcrc | 2 +- packages/vitnode/eslint.config.mjs | 2 + packages/vitnode/tsconfig.json | 2 +- plugins/blog/eslint.config.mjs | 2 + pnpm-lock.yaml | 40 +++++++++++ 23 files changed, 329 insertions(+), 82 deletions(-) create mode 100644 apps/docs/content/docs/dev/email/nodemailer.mdx delete mode 100644 apps/docs/content/docs/dev/email/smtp.mdx create mode 100644 packages/config/eslint.react.config.mjs create mode 100644 packages/nodemailer/.swcrc create mode 100644 packages/nodemailer/README.md create mode 100644 packages/nodemailer/eslint.config.mjs create mode 100644 packages/nodemailer/package.json rename packages/{vitnode/src/api/adapters/email/nodemailer.ts => nodemailer/src/index.ts} (93%) create mode 100644 packages/nodemailer/tsconfig.json diff --git a/.github/workflows/bump_publish.yml b/.github/workflows/bump_publish.yml index c8e5c0255..856155f2b 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 --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 --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 --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/email/nodemailer.mdx b/apps/docs/content/docs/dev/email/nodemailer.mdx new file mode 100644 index 000000000..f55904723 --- /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=xxx +NODE_MAILER_HOST=xxx +NODE_MAILER_PASSWORD=xxx +NOD_EMAILER_USER=xxx +``` + + + 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/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..e13b8d134 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -53,6 +53,7 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitnode/config": "workspace:*", + "@vitnode/nodemailer": "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..ccb53fc7c 100644 --- a/apps/docs/src/vitnode.api.config.ts +++ b/apps/docs/src/vitnode.api.config.ts @@ -1,6 +1,7 @@ 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 { NodemailerEmailAdapter } from "@vitnode/nodemailer"; import { DiscordSSOApiPlugin } from "@vitnode/core/api/adapters/sso/discord"; import { FacebookSSOApiPlugin } from "@vitnode/core/api/adapters/sso/facebook"; import { GoogleSSOApiPlugin } from "@vitnode/core/api/adapters/sso/google"; 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/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/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/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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 9620f3b09..d00ca7dcb 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,9 @@ importers: '@vitnode/config': specifier: workspace:* version: link:../../packages/config + '@vitnode/nodemailer': + specifier: workspace:* + version: link:../../packages/nodemailer babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -313,6 +319,40 @@ importers: 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/vitnode: dependencies: '@dnd-kit/core': From fd09aedd272455b7136e389bb6823f3c15044bc8 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 11 Nov 2025 13:28:32 +0100 Subject: [PATCH 2/3] refactor(email): Move resend to package --- .github/workflows/bump_publish.yml | 6 +- .../content/docs/dev/email/nodemailer.mdx | 8 +-- apps/docs/content/docs/dev/email/resend.mdx | 67 +++++++++++++++++-- apps/docs/package.json | 1 + apps/docs/src/vitnode.api.config.ts | 15 +++-- packages/nodemailer/.npmignore | 6 ++ packages/resend/.npmignore | 6 ++ packages/resend/.swcrc | 25 +++++++ packages/resend/README.md | 20 ++++++ packages/resend/eslint.config.mjs | 17 +++++ packages/resend/package.json | 44 ++++++++++++ .../email/resend.ts => resend/src/index.ts} | 4 +- packages/resend/tsconfig.json | 19 ++++++ packages/vitnode/package.json | 3 - pnpm-lock.yaml | 43 +++++++++--- 15 files changed, 254 insertions(+), 30 deletions(-) create mode 100644 packages/nodemailer/.npmignore create mode 100644 packages/resend/.npmignore create mode 100644 packages/resend/.swcrc create mode 100644 packages/resend/README.md create mode 100644 packages/resend/eslint.config.mjs create mode 100644 packages/resend/package.json rename packages/{vitnode/src/api/adapters/email/resend.ts => resend/src/index.ts} (90%) create mode 100644 packages/resend/tsconfig.json diff --git a/.github/workflows/bump_publish.yml b/.github/workflows/bump_publish.yml index 856155f2b..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 --filter @vitnode/nodemailer --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 --filter @vitnode/nodemailer --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 --filter @vitnode/nodemailer --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/docs/content/docs/dev/email/nodemailer.mdx b/apps/docs/content/docs/dev/email/nodemailer.mdx index f55904723..5cb348ad7 100644 --- a/apps/docs/content/docs/dev/email/nodemailer.mdx +++ b/apps/docs/content/docs/dev/email/nodemailer.mdx @@ -61,10 +61,10 @@ export const vitNodeApiConfig = buildApiConfig({ Add the following environment variables to your `.env` file: ```bash title=".env" -NODE_MAILER_FROM=xxx -NODE_MAILER_HOST=xxx -NODE_MAILER_PASSWORD=xxx -NOD_EMAILER_USER=xxx +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/package.json b/apps/docs/package.json index e13b8d134..9530e8a27 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -54,6 +54,7 @@ "@types/react-dom": "^19.2.2", "@vitnode/config": "workspace:*", "@vitnode/nodemailer": "workspace:*", + "@vitnode/resend": "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 ccb53fc7c..be113d3ed 100644 --- a/apps/docs/src/vitnode.api.config.ts +++ b/apps/docs/src/vitnode.api.config.ts @@ -1,5 +1,6 @@ import { blogApiPlugin } from "@vitnode/blog/config.api"; import { NodeCronAdapter } from "@vitnode/core/api/adapters/cron/node-cron.adapter"; +import { ResendEmailAdapter } from "@vitnode/resend"; import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; import { DiscordSSOApiPlugin } from "@vitnode/core/api/adapters/sso/discord"; @@ -33,11 +34,15 @@ export const vitNodeApiConfig = buildApiConfig({ duration: 60, // per 60 seconds }, email: { - 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, + // 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, + // }), + adapter: ResendEmailAdapter({ + apiKey: process.env.RESEND_API_KEY, + from: process.env.RESEND_FROM_EMAIL, }), logo: { text: "VitNode Email Test", 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/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/package.json b/packages/vitnode/package.json index 77bad6fd8..42262cc83 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", @@ -126,12 +125,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/pnpm-lock.yaml b/pnpm-lock.yaml index d00ca7dcb..d77712e3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,9 @@ importers: '@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 @@ -353,6 +356,37 @@ importers: 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': @@ -400,9 +434,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 @@ -415,9 +446,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 @@ -458,9 +486,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 From a096ddcb4b980a6101df97ad34754d211d8f246d Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 11 Nov 2025 19:06:36 +0100 Subject: [PATCH 3/3] refactor(cron): Move node-cron to package --- .../docs/dev/captcha/captcha_preview.png | Bin 0 -> 43895 bytes .../docs/dev/captcha/custom-adapter.mdx | 116 ++++++ apps/docs/content/docs/dev/captcha/index.mdx | 144 ++----- apps/docs/content/docs/dev/captcha/meta.json | 4 + .../content/docs/dev/cron/custom-adapter.mdx | 83 +++++ apps/docs/content/docs/dev/cron/index.mdx | 86 ++--- apps/docs/content/docs/dev/cron/meta.json | 2 +- apps/docs/content/docs/dev/cron/node-cron.mdx | 28 +- apps/docs/content/docs/dev/cron/rest-api.mdx | 9 +- .../content/docs/dev/email/custom-adapter.mdx | 95 +++++ apps/docs/content/docs/dev/email/index.mdx | 98 +---- apps/docs/content/docs/dev/email/meta.json | 8 +- .../content/docs/dev/sso/custom-adapter.mdx | 352 ++++++++++++++++++ apps/docs/content/docs/dev/sso/index.mdx | 344 ----------------- apps/docs/content/docs/dev/sso/meta.json | 4 + apps/docs/package.json | 1 + apps/docs/src/vitnode.api.config.ts | 25 +- packages/node-cron/.npmignore | 6 + packages/node-cron/.swcrc | 25 ++ packages/node-cron/README.md | 20 + packages/node-cron/eslint.config.mjs | 17 + packages/node-cron/package.json | 46 +++ .../src/index.ts} | 3 +- packages/node-cron/tsconfig.json | 19 + packages/vitnode/package.json | 1 - .../modules/cron/helpers/process-cron-jobs.ts | 4 +- .../lib/api/validate-cron-schedule.test.ts | 171 +++++++++ .../src/lib/api/validate-cron-schedule.ts | 128 +++++++ packages/vitnode/vitest.config.ts | 14 + .../src/api/modules/categories/test.route.ts | 2 +- pnpm-lock.yaml | 37 +- 31 files changed, 1239 insertions(+), 653 deletions(-) create mode 100644 apps/docs/content/docs/dev/captcha/captcha_preview.png create mode 100644 apps/docs/content/docs/dev/captcha/custom-adapter.mdx create mode 100644 apps/docs/content/docs/dev/captcha/meta.json create mode 100644 apps/docs/content/docs/dev/cron/custom-adapter.mdx create mode 100644 apps/docs/content/docs/dev/email/custom-adapter.mdx create mode 100644 apps/docs/content/docs/dev/sso/custom-adapter.mdx create mode 100644 apps/docs/content/docs/dev/sso/meta.json create mode 100644 packages/node-cron/.npmignore create mode 100644 packages/node-cron/.swcrc create mode 100644 packages/node-cron/README.md create mode 100644 packages/node-cron/eslint.config.mjs create mode 100644 packages/node-cron/package.json rename packages/{vitnode/src/api/adapters/cron/node-cron.adapter.ts => node-cron/src/index.ts} (73%) create mode 100644 packages/node-cron/tsconfig.json create mode 100644 packages/vitnode/src/lib/api/validate-cron-schedule.test.ts create mode 100644 packages/vitnode/src/lib/api/validate-cron-schedule.ts 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 0000000000000000000000000000000000000000..b7d68a5ab9b43b137dd8b07ede25126f61125521 GIT binary patch literal 43895 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 Ai~s-t literal 0 HcmV?d00001 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/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/package.json b/apps/docs/package.json index 9530e8a27..633557eba 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -55,6 +55,7 @@ "@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 be113d3ed..49cb91b54 100644 --- a/apps/docs/src/vitnode.api.config.ts +++ b/apps/docs/src/vitnode.api.config.ts @@ -1,12 +1,11 @@ import { blogApiPlugin } from "@vitnode/blog/config.api"; -import { NodeCronAdapter } from "@vitnode/core/api/adapters/cron/node-cron.adapter"; -import { ResendEmailAdapter } from "@vitnode/resend"; - -import { NodemailerEmailAdapter } from "@vitnode/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 = @@ -34,16 +33,16 @@ export const vitNodeApiConfig = buildApiConfig({ duration: 60, // per 60 seconds }, email: { - // 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, - // }), - adapter: ResendEmailAdapter({ - apiKey: process.env.RESEND_API_KEY, - from: process.env.RESEND_FROM_EMAIL, + 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, }), + // 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/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/vitnode/package.json b/packages/vitnode/package.json index 42262cc83..f8419f925 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -62,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", 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/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/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 d77712e3f..5dd64f27d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ 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 @@ -322,6 +325,37 @@ 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: @@ -534,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