UNPKG

@tradecrush/next-route-guard

Version:

Convention-based route authentication middleware for Next.js applications

692 lines (555 loc) โ€ข 24.7 kB
# @tradecrush/next-route-guard > ๐Ÿš€ **NEW v0.2.4**: Improved nested optional catch-all route handling, enhanced tests, fixed inconsistencies, and code cleanup > > โš ๏ธ **BREAKING CHANGE**: The primary function and types have been renamed: > - `createRouteAuthMiddleware` โ†’ `createRouteGuardMiddleware` > - `RouteAuthOptions` โ†’ `RouteGuardOptions` > > โšก **OPTIMIZED**: Trie-based route matching (90ร— faster), improved optional catch-all route handling, and complete Next.js version compatibility! A convention-based route authentication middleware for Next.js applications with App Router (Next.js 13.4.0 and up), fully tested and compatible with all major Next.js versions. [![npm version](https://img.shields.io/npm/v/@tradecrush/next-route-guard.svg)](https://www.npmjs.com/package/@tradecrush/next-route-guard) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Unit Tests](https://github.com/tradecrush/next-route-guard/actions/workflows/tests.yml/badge.svg)](https://github.com/tradecrush/next-route-guard/actions/workflows/tests.yml) [![Next.js Compatibility](https://github.com/tradecrush/next-route-guard/actions/workflows/compatibility.yml/badge.svg)](https://github.com/tradecrush/next-route-guard/actions/workflows/compatibility.yml) ## Table of Contents - [Features](#features) - Key capabilities and advantages - [Why Next Route Guard?](#why-next-route-guard) - Problems solved and benefits - [Installation](#installation) - How to add to your project - [Quick Start](#-quick-start) - Get up and running in minutes - [How It Works](#how-it-works) - Under the hood: build & runtime processes - [Route Protection Strategy](#-route-protection-strategy) - How routes are protected - [API Reference](#-api-reference) - Complete function and type documentation - [Package Exports](#-package-exports) - What's available in the package - [Development Mode](#-development-mode) - Tools for local development - [CLI Tools](#cli-tools) - Command-line utilities for route analysis - [Advanced Configuration](#advanced-configuration) - Customization options - [Example Scenarios](#example-scenarios) - Common route protection patterns - [Compatibility](#-compatibility) - Supported Next.js versions - [License](#-license) - MIT License information ## Features - **๐Ÿ”’ Convention-based Protection**: Protect routes using directory naming conventions - **โšก Middleware-Based**: Works with Next.js Edge middleware for fast authentication checks - **๐Ÿ—๏ธ Build-time Analysis**: Generates route maps during build for Edge runtime compatibility - **๐Ÿ”„ Inheritance**: Child routes inherit protection status from parent routes - **๐Ÿ”€ Dynamic Routes**: Full support for Next.js dynamic routes, catch-all routes, and optional segments - **โš™๏ธ Zero Runtime Overhead**: Route protection rules are compiled at build time - **๐Ÿš€ Hyper-Optimized**: Uses trie-based algorithms that are 90ร— faster than linear search - **๐Ÿ› ๏ธ Flexible Configuration**: Customize authentication logic, redirection behavior, and more - **๐Ÿ‘€ Watch Mode**: Development tool that updates route maps as you add or remove routes - **โœ… Fully Compatible**: Tested with Next.js 13.4.0, 14.0.0 and 15.0.0 ## Why Next Route Guard? Next.js App Router is great, but it lacks a simple way to protect routes based on authentication. Next Route Guard solves this problem by providing a convention-based approach to route protection: - **No Duplicate Auth Logic**: Define your auth rules once in middleware, not in every page - **Directory-Based**: Organize routes naturally using Next.js route groups like `(public)` and `(protected)` - **Works with Any Auth Provider**: Compatible with any authentication system (JWT, cookies, OAuth, etc.) - **Edge-Compatible**: Works with Next.js Edge middleware for optimal performance - **TypeScript Support**: Fully typed for excellent developer experience ## Installation ```bash npm install @tradecrush/next-route-guard # or yarn add @tradecrush/next-route-guard # or pnpm add @tradecrush/next-route-guard ``` ## โญ Quick Start 1. **Organize your routes** using the `(public)` and `(protected)` route groups: ``` app/ โ”œโ”€โ”€ (public)/ # Public routes (no authentication required) โ”‚ โ”œโ”€โ”€ login/ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx โ”‚ โ””โ”€โ”€ about/ โ”‚ โ””โ”€โ”€ page.tsx โ”œโ”€โ”€ (protected)/ # Protected routes (authentication required) โ”‚ โ”œโ”€โ”€ dashboard/ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx โ”‚ โ””โ”€โ”€ settings/ โ”‚ โ””โ”€โ”€ page.tsx โ””โ”€โ”€ layout.tsx # Root layout (applies to all routes) ``` 2. **Add the route map generation to your build script** in package.json: ```json { "scripts": { "build": "next-route-guard-generate && next build", "dev": "next-route-guard-watch & next dev" } } ``` 3. **Create a middleware.ts file** in your project root: ```typescript // middleware.ts import { createRouteGuardMiddleware } from '@tradecrush/next-route-guard'; import routeMap from './app/route-map.json'; import { NextResponse } from 'next/server'; export default createRouteGuardMiddleware({ routeMap, isAuthenticated: async (request) => { // Replace with your actual authentication logic // This is just an example using cookies const token = request.cookies.get('auth-token')?.value; return !!token; // Or using JWT from Authorization header // const authHeader = request.headers.get('Authorization'); // return authHeader?.startsWith('Bearer ') || false; }, onUnauthenticated: (request) => { // Redirect to login with return URL const url = request.nextUrl.clone(); url.pathname = '/login'; url.searchParams.set('from', request.nextUrl.pathname); return NextResponse.redirect(url); } }); export const config = { matcher: [ // Match all routes except static files, api routes, and other special paths '/((?!_next/static|_next/image|favicon.ico).*)' ] }; ``` 4. **That's it!** Your routes are now protected based on their directory structure. ## How It Works The package works in two stages: ### 1. Build Time: Route Analysis During your build process, the `next-route-guard-generate` command: - Scans your Next.js app directory structure - Identifies routes and their protection status based on route groups - Generates a static `route-map.json` file containing protected and public routes ### 2. Runtime: Middleware Protection The middleware: - Uses the generated route map to build an optimized route trie data structure - Efficiently matches request paths against the trie to determine protection status - Checks authentication status for protected routes - Redirects unauthenticated users to login (or your custom logic) - Allows direct access to public routes ### Performance Benchmarks Performance measurements with 1400 routes in the route map: ``` Routes: 1400 Average time per request: 0.003ms Test path | Time per request ------------------------------------------|---------------- /public/page-250 | 0.002ms /protected/page-499 | 0.004ms /public/dynamic-50/12345 | 0.002ms /protected/catch-25/a/b/c/d/e/f/g/h/i/j | 0.004ms /protected/catch-49/a/b/c/edit | 0.002ms /unknown/path/not/found | 0.003ms ``` These benchmarks were run on Node.js v22.14.0 on a MacBook Pro (M3 Max), with 1000 requests per path. #### Performance Comparison with Previous Version Comparing to the previous linear search implementation (v0.1.4): | Implementation | Avg time/request | Speedup | |----------------|------------------|---------| | Linear search | 0.271ms | 1ร— | | Trie-based | 0.003ms | 90.3ร— | The trie-based implementation is **90.3ร— faster** on average, with particular improvements for: - Complex paths with many segments (43.8ร— faster for catch-all routes with `/protected/catch-25/a/b/c/d/e/f/g/h/i/j` going from 0.175ms to 0.004ms) - Non-existent routes (387ร— faster with `/unknown/path/not/found` going from 1.162ms to 0.003ms) ### Route Trie Optimization Next Route Guard uses a specialized trie (prefix tree) data structure for route matching that dramatically improves performance: - **O(k) Matching Complexity**: Routes are matched in time proportional to the path depth (k), not the total number of routes (n) - **Space-Efficient**: Shared path prefixes are stored once in the tree structure - **Advanced Route Pattern Support**: Optimized handling of all Next.js route patterns: - Dynamic segments: `/users/[id]` - Catch-all routes: `/docs/[...slug]` - Optional catch-all: `/docs/[[...slug]]` - Complex paths with rest segments: `/docs/[...slug]/edit` - Multiple dynamic segments: `/products/[category]/[id]/details` - Mixed dynamic and catch-all: `/articles/[section]/[...tags]/share` - **One-time Initialization**: The trie is built once when middleware initializes, then reused for all requests - **Consistent Performance**: Lookup time remains stable regardless of route count (O(k) vs O(nร—m)) - **Protection Inheritance**: Route protection statuses naturally flow through the tree structure #### How the Route Trie Works The route trie transforms your app directory structure into a tree representation that efficiently handles route protection. Let's look at a comprehensive example of a Next.js app directory with various route patterns: ``` app/ โ”œโ”€โ”€ (public)/ # Public routes group โ”‚ โ”œโ”€โ”€ about/ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx # /about โ”‚ โ”œโ”€โ”€ products/ โ”‚ โ”‚ โ”œโ”€โ”€ page.tsx # /products โ”‚ โ”‚ โ””โ”€โ”€ [id]/ โ”‚ โ”‚ โ”œโ”€โ”€ page.tsx # /products/[id] โ”‚ โ”‚ โ”œโ”€โ”€ reviews/ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx # /products/[id]/reviews โ”‚ โ”‚ โ””โ”€โ”€ (protected)/ # Nested protected group inside public โ”‚ โ”‚ โ””โ”€โ”€ edit/ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx # /products/[id]/edit (protected) โ”‚ โ””โ”€โ”€ help/ โ”‚ โ”œโ”€โ”€ page.tsx # /help โ”‚ โ””โ”€โ”€ (protected)/ # Nested protected group โ”‚ โ””โ”€โ”€ admin/ โ”‚ โ””โ”€โ”€ page.tsx # /help/admin (protected) โ”œโ”€โ”€ (protected)/ # Protected routes group โ”‚ โ”œโ”€โ”€ dashboard/ โ”‚ โ”‚ โ”œโ”€โ”€ page.tsx # /dashboard โ”‚ โ”‚ โ”œโ”€โ”€ @stats/ # Parallel route โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx # /dashboard/@stats โ”‚ โ”‚ โ””โ”€โ”€ settings/ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx # /dashboard/settings โ”‚ โ”œโ”€โ”€ docs/ โ”‚ โ”‚ โ”œโ”€โ”€ page.tsx # /docs โ”‚ โ”‚ โ”œโ”€โ”€ [...slug]/ # Required catch-all โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx # /docs/[...slug] โ”‚ โ”‚ โ””โ”€โ”€ (public)/ # Nested public group inside protected โ”‚ โ”‚ โ””โ”€โ”€ preview/ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx # /docs/preview (public) โ”‚ โ””โ”€โ”€ admin/ โ”‚ โ”œโ”€โ”€ page.tsx # /admin (protected) โ”‚ โ””โ”€โ”€ [[...slug]]/ # Optional catch-all (protects all subpaths) โ”‚ โ””โ”€โ”€ page.tsx # /admin/settings, /admin/users, etc. โ””โ”€โ”€ layout.tsx ``` This directory structure is converted to the following route trie: ``` / (root) โ”œโ”€โ”€ about (public) # From (public)/about โ”œโ”€โ”€ products (public) # From (public)/products โ”‚ โ””โ”€โ”€ [id] (dynamic - public) # From (public)/products/[id] โ”‚ โ”œโ”€โ”€ reviews (public) # From (public)/products/[id]/reviews โ”‚ โ””โ”€โ”€ edit (protected) # From (public)/products/[id]/(protected)/edit โ”œโ”€โ”€ help (public) # From (public)/help โ”‚ โ””โ”€โ”€ admin (protected) # From (public)/help/(protected)/admin โ”œโ”€โ”€ dashboard (protected) # From (protected)/dashboard โ”‚ โ”œโ”€โ”€ @stats (protected) # From (protected)/dashboard/@stats โ”‚ โ””โ”€โ”€ settings (protected) # From (protected)/dashboard/settings โ”œโ”€โ”€ docs (protected) # From (protected)/docs โ”‚ โ”œโ”€โ”€ [...slug] (protected) # From (protected)/docs/[...slug] โ”‚ โ””โ”€โ”€ preview (public) # From (protected)/docs/(public)/preview โ””โ”€โ”€ admin (protected) # From (protected)/admin โ””โ”€โ”€ [[...slug]] (protected) # From (protected)/admin/[[...slug]] ``` When a request arrives: 1. The URL is split into segments (e.g., `/docs/api/auth` โ†’ `["docs", "api", "auth"]`) 2. The trie is traversed segment-by-segment, matching: - Exact matches first (highest priority) - Dynamic parameters next (e.g., `[id]`) - Catch-all segments as needed (e.g., `[...slug]`, `[[...optionalPath]]`) 3. Protection status is determined from the matched node or parent nodes - `/docs/api` matches the `[...slug]` catch-all โ†’ protected - `/docs/preview` matches an exact path with custom protection โ†’ public - `/products/123/edit` has a nested protection override โ†’ protected - `/help/admin` has a nested protection override โ†’ protected - `/admin/users` matches the optional catch-all โ†’ protected - `/admin` is protected as the base path for the catch-all - `/dashboard/profile` doesn't exist but falls under protected parent โ†’ protected - `/about/team` doesn't exist but falls under public parent โ†’ public This approach provides orders of magnitude better performance than a linear search through route lists, especially for applications with many routes or complex routing patterns. ## ๐Ÿ” Route Protection Strategy Next Route Guard uses Next.js [Route Groups](https://nextjs.org/docs/app/building-your-application/routing/route-groups) to determine which routes are protected and which are public. ### Directory Conventions - Routes in `(public)` groups are **public** and don't require authentication - Routes in `(protected)` groups are **protected** and require authentication - Routes inherit protection status from their parent directories - Routes without an explicit protection status are **protected by default** (you can change this) ### Custom Group Names You can use custom group names instead of the default `(public)` and `(protected)`: ```bash npx next-route-guard-generate --app-dir ./app --output ./app/route-map.json --public "(open),(guest)" --protected "(auth),(admin)" ``` This allows you to use groups like: ``` app/ โ”œโ”€โ”€ (open)/ # Public routes (custom name) โ”‚ โ”œโ”€โ”€ about/ โ”‚ โ””โ”€โ”€ signup/ โ”œโ”€โ”€ (guest)/ # Also public routes (custom name) โ”‚ โ””โ”€โ”€ features/ โ”œโ”€โ”€ (auth)/ # Protected routes (custom name) โ”‚ โ”œโ”€โ”€ dashboard/ โ”‚ โ””โ”€โ”€ settings/ โ”œโ”€โ”€ (admin)/ # Also protected routes (custom name) โ”‚ โ””โ”€โ”€ users/ โ”œโ”€โ”€ layout.tsx โ””โ”€โ”€ page.tsx ``` ### Nested Groups and Precedence Nested groups take precedence over parent groups. This allows more fine-grained control: ``` app/ โ”œโ”€โ”€ (public)/ # Public routes โ”‚ โ”œโ”€โ”€ about/ โ”‚ โ”œโ”€โ”€ docs/ โ”‚ โ”‚ โ”œโ”€โ”€ public-page/ โ”‚ โ”‚ โ””โ”€โ”€ (protected)/ # Protected routes within public section โ”‚ โ”‚ โ””โ”€โ”€ admin/ โ”‚ โ””โ”€โ”€ signup/ โ””โ”€โ”€ (protected)/ # Protected routes โ”œโ”€โ”€ dashboard/ โ””โ”€โ”€ settings/ โ””โ”€โ”€ (public)/ # Public routes within protected section โ””โ”€โ”€ help/ ``` With this structure: - `/about` is public (from parent `(public)`) - `/docs/public-page` is public (from parent `(public)`) - `/docs/admin` is protected (from nested `(protected)`) - `/dashboard` is protected (from parent `(protected)`) - `/settings/help` is public (from nested `(public)`) ## ๐Ÿ“š API Reference The package provides several functions and types to help with route protection: ### createRouteGuardMiddleware The main function that creates a Next.js middleware function for route protection. ```typescript function createRouteGuardMiddleware(options: RouteGuardOptions): Middleware ``` #### RouteGuardOptions ```typescript interface RouteGuardOptions { /** * Function to determine if a user is authenticated */ isAuthenticated: (request: NextRequest) => Promise<boolean> | boolean; /** * Function to handle unauthenticated requests * Default: Redirects to /login with the original URL as a 'from' parameter */ onUnauthenticated?: (request: NextRequest) => Promise<NextResponse> | NextResponse; /** * Map of protected and public routes */ routeMap: RouteMap; /** * Default behavior for routes not in the route map * Default: true (routes are protected by default) */ defaultProtected?: boolean; /** * URLs to exclude from authentication checks * Default: ['/api/(.*)'] (excludes all API routes) */ excludeUrls?: (string | RegExp)[]; } ``` ### Middleware Chaining You can chain middleware functions to create a pipeline: ```typescript // middleware.ts import { createRouteGuardMiddleware, chain } from '@tradecrush/next-route-guard'; import routeMap from './app/route-map.json'; import { NextResponse } from 'next/server'; // Logging middleware const withLogging = (next) => { return async (request) => { console.log(`Request: ${request.method} ${request.url}`); return next(request); }; }; // Auth middleware const withAuth = createRouteGuardMiddleware({ routeMap, isAuthenticated: (request) => { const token = request.cookies.get('token')?.value; return !!token; }, onUnauthenticated: (request) => { const url = new URL('/login', request.url); return NextResponse.redirect(url); } }); // Header middleware const withHeaders = (next) => { return async (request) => { const response = await next(request); if (response) { response.headers.set('X-Powered-By', 'Next Route Guard'); } return response; }; }; // Export the middleware chain export default chain([withLogging, withAuth, withHeaders]); // Add a matcher export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], }; ``` ## ๐Ÿ“ฆ Package Exports The package exports the following: ```typescript { // Main middleware creator createRouteGuardMiddleware, // Utility for chaining middleware chain, // Route map generator (for build scripts) generateRouteMap, // Types type RouteGuardOptions, type RouteMap, type NextMiddleware } ``` ## ๐Ÿ› ๏ธ Development Mode During development, you can use the watch mode to automatically update the route map when files change: ```bash npx next-route-guard-watch --app-dir ./app --output ./app/route-map.json ``` This will watch for changes in your app directory and update the route map when files are added, modified, or deleted. ## CLI Tools The package includes two command-line tools to help manage your route maps: ### next-route-guard-generate Generates the route map file at build time: ```bash # Basic usage with defaults next-route-guard-generate # With custom options next-route-guard-generate --app-dir ./src/app --output ./src/lib/route-map.json ``` Options: ``` --app-dir <path> Path to the app directory (default: ./app) --output <path> Path to the output JSON file (default: ./app/route-map.json) --public <patterns> Comma-separated list of public route patterns (default: (public)) --protected <patterns> Comma-separated list of protected route patterns (default: (protected)) --help Display this help message ``` ### next-route-guard-watch Watches for route changes during development: ```bash # Basic usage next-route-guard-watch # With custom options next-route-guard-watch --app-dir ./src/app --output ./src/lib/route-map.json ``` Options: Same as `next-route-guard-generate` ## Advanced Configuration ### Excluding URLs Some URL patterns can be excluded from authentication checks: ```typescript createRouteGuardMiddleware({ // ... excludeUrls: [ '/api/(.*)', // Exclude API routes '/images/(.*)', // Exclude static image paths '/cdn-proxy/(.*)' // Exclude CDN proxy paths ] }); ``` ### Default Protection Mode By default, routes are protected unless explicitly marked as public. You can change this behavior: ```typescript createRouteGuardMiddleware({ // ... defaultProtected: false // Routes are public by default }); ``` This means routes without explicit protection groups will be treated as public. ### Custom Authentication Logic Implement your own authentication logic by providing an `isAuthenticated` function: ```typescript createRouteGuardMiddleware({ // ... isAuthenticated: async (request) => { // Check for a JWT in the Authorization header const authHeader = request.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return false; } const token = authHeader.split(' ')[1]; try { // Verify the token (using your preferred JWT library) const payload = await verifyJwt(token); return !!payload; } catch (error) { return false; } } }); ``` ### Custom Redirection Behavior Override the default redirection behavior: ```typescript createRouteGuardMiddleware({ // ... onUnauthenticated: (request) => { // Different behavior based on route type const url = request.nextUrl.clone(); // If it's an API request, return a 401 response if (request.nextUrl.pathname.startsWith('/api/')) { return new NextResponse( JSON.stringify({ error: 'Authentication required' }), { status: 401, headers: { 'Content-Type': 'application/json' } } ); } // For dashboard routes, redirect to a custom login page if (request.nextUrl.pathname.startsWith('/dashboard/')) { url.pathname = '/dashboard-login'; url.searchParams.set('from', request.nextUrl.pathname); return NextResponse.redirect(url); } // Default login redirect url.pathname = '/login'; url.searchParams.set('from', request.nextUrl.pathname); return NextResponse.redirect(url); } }); ``` ## Example Scenarios ### Simple Public/Protected Split ``` app/ โ”œโ”€โ”€ (public)/ โ”‚ โ”œโ”€โ”€ login/ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx โ”‚ โ”œโ”€โ”€ register/ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx โ”‚ โ””โ”€โ”€ about/ โ”‚ โ””โ”€โ”€ page.tsx โ””โ”€โ”€ (protected)/ โ”œโ”€โ”€ dashboard/ โ”‚ โ””โ”€โ”€ page.tsx โ””โ”€โ”€ profile/ โ””โ”€โ”€ page.tsx ``` ### Mixed Hierarchies ``` app/ โ”œโ”€โ”€ (public)/ โ”‚ โ”œโ”€โ”€ help/ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx โ”‚ โ””โ”€โ”€ login/ โ”‚ โ””โ”€โ”€ page.tsx โ”œโ”€โ”€ dashboard/ # Protected (default) โ”‚ โ”œโ”€โ”€ (public)/ โ”‚ โ”‚ โ””โ”€โ”€ preview/ # Public route inside a protected area โ”‚ โ”‚ โ””โ”€โ”€ page.tsx โ”‚ โ”œโ”€โ”€ overview/ # Protected โ”‚ โ”‚ โ””โ”€โ”€ page.tsx โ”‚ โ””โ”€โ”€ settings/ # Protected โ”‚ โ””โ”€โ”€ page.tsx โ””โ”€โ”€ layout.tsx ``` In this example, `/dashboard/preview` is public even though it's inside the protected `/dashboard` area. ### Dynamic Routes ``` app/ โ”œโ”€โ”€ (public)/ โ”‚ โ””โ”€โ”€ articles/ โ”‚ โ”œโ”€โ”€ page.tsx โ”‚ โ””โ”€โ”€ [slug]/ # Public article pages โ”‚ โ””โ”€โ”€ page.tsx โ”œโ”€โ”€ (protected)/ โ”‚ โ””โ”€โ”€ users/ โ”‚ โ”œโ”€โ”€ page.tsx โ”‚ โ””โ”€โ”€ [id]/ # Protected user profiles โ”‚ โ””โ”€โ”€ page.tsx โ””โ”€โ”€ docs/ # Protected by default โ”œโ”€โ”€ [...slug]/ # Catch-all route โ”‚ โ””โ”€โ”€ page.tsx โ””โ”€โ”€ page.tsx ``` Here, article pages with dynamic slugs are public, while user profiles with dynamic IDs are protected. ## ๐Ÿงช Compatibility Next Route Guard is fully tested with the following Next.js versions: - โœ… Next.js 13.4.0 (App Router initial release) - โœ… Next.js 14.0.0 - โœ… Next.js 15.0.0 The middleware is optimized for the Edge runtime and uses efficient algorithms for route matching, making it suitable for production use with minimal overhead. ## ๐Ÿ“„ License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. --- Made with โค๏ธ by [Tradecrush](https://www.tradecrush.io)