├── .changeset ├── README.md └── config.json ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package.json ├── packages ├── bunnygram │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ ├── adapters │ │ │ ├── basic │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── qstash │ │ │ │ ├── config.test.ts │ │ │ │ ├── config.ts │ │ │ │ └── index.ts │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── scheduler │ │ │ ├── config │ │ │ │ ├── config.test.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── intellisense-test.ts │ │ │ ├── messages.ts │ │ │ ├── receive │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ └── send │ │ │ │ └── index.ts │ │ └── utilities │ │ │ ├── http │ │ │ └── index.ts │ │ │ └── types.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── docs │ ├── .gitignore │ ├── CHANGELOG.md │ ├── next.config.js │ ├── package.json │ ├── pages │ │ ├── _app.tsx │ │ ├── _meta.json │ │ ├── adapters │ │ │ ├── _meta.json │ │ │ ├── basic.mdx │ │ │ ├── custom.mdx │ │ │ └── qstash.mdx │ │ ├── config.mdx │ │ ├── faq.mdx │ │ └── index.mdx │ ├── public │ │ └── cover.png │ ├── theme.config.tsx │ └── tsconfig.json └── nextjs-example │ ├── .gitignore │ ├── .vscode │ └── settings.json │ ├── CHANGELOG.md │ ├── README.md │ ├── next.config.js │ ├── package.json │ ├── src │ ├── app │ │ ├── example-route │ │ │ └── route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ └── tasks │ │ └── send-email.ts │ └── tsconfig.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | !.vscode/extensions.json 17 | .idea 18 | .DS_Store 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | coverage 25 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.activeBackground": "#def7fe", 4 | "activityBar.background": "#def7fe", 5 | "activityBar.foreground": "#15202b", 6 | "activityBar.inactiveForeground": "#15202b99", 7 | "activityBarBadge.background": "#fa64d9", 8 | "activityBarBadge.foreground": "#15202b", 9 | "commandCenter.border": "#15202b99", 10 | "sash.hoverBorder": "#def7fe", 11 | "statusBar.background": "#acecfd", 12 | "statusBar.foreground": "#15202b", 13 | "statusBarItem.hoverBackground": "#7ae1fc", 14 | "statusBarItem.remoteBackground": "#acecfd", 15 | "statusBarItem.remoteForeground": "#15202b", 16 | "titleBar.activeBackground": "#acecfd", 17 | "titleBar.activeForeground": "#15202b", 18 | "titleBar.inactiveBackground": "#acecfd99", 19 | "titleBar.inactiveForeground": "#15202b99" 20 | }, 21 | "peacock.color": "#acecfd" 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sarim Abbas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bunnygram 🐇 📬 2 | 3 | - [Bunnygram 🐇 📬](#bunnygram--) 4 | - [Introduction](#introduction) 5 | - [Installation](#installation) 6 | - [Documentation](#documentation) 7 | - [Contributing](#contributing) 8 | 9 | ## Introduction 10 | 11 | Bunnygram is a task scheduler for Next.js. You might need it if you want to defer a computationally expensive or long-running task in the background. 12 | 13 | ## Installation 14 | 15 | ```sh 16 | pnpm add bunnygram 17 | ``` 18 | 19 | You can also use `npm` or `yarn` etc. 20 | 21 | Note that Bunnygram is ESM only. 22 | 23 | NPM link: https://www.npmjs.com/package/bunnygram 24 | 25 | ## Documentation 26 | 27 | Find the latest documentation on 28 | 29 | ## Contributing 30 | 31 | I would appreciate PRs that: 32 | 33 | - Add more tests, particularly those mocking the HTTP request 34 | - Add support for other "adapters" e.g. Zeplo. 35 | - Improve type inference when there are no user provided types for job payload and response 36 | - Anything else! 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bunnygram-monorepo", 3 | "version": "1.0.0", 4 | "repository": "https://github.com/sarimabbas/bunnygram", 5 | "homepage": "https://bunnygram.vercel.app", 6 | "description": "", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@changesets/cli": "^2.26.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/bunnygram/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # bunnygram 2 | 3 | ## 2.0.0 4 | 5 | ### Major Changes 6 | 7 | - Breaking change: 8 | 9 | This version updates bunnygram to use Fetch API and work with Next.js 13.2 route handlers inside the `app` directory. Bunnygram will no longer work with `pages` directory API routes. 10 | 11 | Why this change was made: 12 | 13 | The ecosystem seems to be moving towards the new `app` directory, and this change simplifies Bunnygram's code significantly. 14 | 15 | How you should update your code: 16 | 17 | Please consult the docs for how to update your code. The minimum required Next.js version is 13.2. The code changes are minimal (mostly moving from `pages` to `app` directory). Please open an issue if you encounter buggy behavior. Thanks! 18 | -------------------------------------------------------------------------------- /packages/bunnygram/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bunnygram", 3 | "version": "2.0.0", 4 | "repository": "https://github.com/sarimabbas/bunnygram", 5 | "homepage": "https://bunnygram.vercel.app", 6 | "license": "MIT", 7 | "files": [ 8 | "dist", 9 | "src" 10 | ], 11 | "module": "./dist/index.mjs", 12 | "types": "./dist/index.d.ts", 13 | "exports": { 14 | ".": { 15 | "import": "./dist/index.mjs", 16 | "types": "./dist/index.d.ts" 17 | } 18 | }, 19 | "scripts": { 20 | "build": "tsup --config ./tsup.config.ts", 21 | "dev": "pnpm run build --watch src", 22 | "prepublishOnly": "pnpm run build", 23 | "test": "vitest", 24 | "coverage": "vitest run --coverage" 25 | }, 26 | "dependencies": { 27 | "@upstash/qstash": "^0.3.6", 28 | "@whatwg-node/fetch": "^0.8.5", 29 | "zod": "^3.21.4" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^18.15.13", 33 | "@vitest/coverage-c8": "^0.30.1", 34 | "react": "^18.2.0", 35 | "react-dom": "^18.2.0", 36 | "tsup": "^6.7.0", 37 | "typescript": "^5.0.4", 38 | "vitest": "^0.30.1", 39 | "zx": "^7.2.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/bunnygram/src/adapters/basic/index.ts: -------------------------------------------------------------------------------- 1 | import { IAdapter } from "../types"; 2 | 3 | export interface IBasicAdapterProps {} 4 | 5 | export const BasicAdapter = (): IAdapter => { 6 | return { 7 | send: async (sendProps) => { 8 | const { payload, url } = sendProps; 9 | try { 10 | await fetch(url, { 11 | method: "POST", 12 | body: JSON.stringify(payload), 13 | headers: { 14 | "Content-Type": "application/json", 15 | }, 16 | }); 17 | return { 18 | error: false, 19 | message: `POST to ${url} succeeded`, 20 | messageId: new Date().toISOString(), 21 | }; 22 | } catch (err) { 23 | console.error(err); 24 | return { 25 | error: true, 26 | message: `POST to ${url} failed`, 27 | }; 28 | } 29 | }, 30 | verify: async () => { 31 | return { 32 | verified: true, 33 | }; 34 | }, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /packages/bunnygram/src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./qstash"; 2 | export * from "./basic"; 3 | export * from "./types"; 4 | -------------------------------------------------------------------------------- /packages/bunnygram/src/adapters/qstash/config.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, vi } from "vitest"; 2 | import { getToken, getCurrentSigningKey, getNextSigningKey } from "./config"; 3 | 4 | test("getToken with override", () => { 5 | const token = getToken({ qstashToken: "1234" }); 6 | expect(token).toBe("1234"); 7 | }); 8 | 9 | test("getToken with env", () => { 10 | vi.stubEnv("NEXT_PUBLIC_QSTASH_TOKEN", "5678"); 11 | const token = getToken(); 12 | expect(token).toBe("5678"); 13 | }); 14 | 15 | test("getToken with env and override", () => { 16 | vi.stubEnv("QSTASH_TOKEN", "5678"); 17 | const token = getToken({ qstashToken: "1234" }); 18 | expect(token).toBe("1234"); 19 | }); 20 | 21 | test("getCurrentSigningKey with env and override", () => { 22 | vi.stubEnv("QSTASH_CURRENT_SIGNING_KEY", "5678"); 23 | const currentSigningKey = getCurrentSigningKey({ 24 | qstashCurrentSigningKey: "1234", 25 | }); 26 | expect(currentSigningKey).toBe("1234"); 27 | }); 28 | 29 | test("getNextSigningKey with env and override", () => { 30 | vi.stubEnv("QSTASH_NEXT_SIGNING_KEY", "5678"); 31 | const nextSigningKey = getNextSigningKey({ 32 | qstashNextSigningKey: "1234", 33 | }); 34 | expect(nextSigningKey).toBe("1234"); 35 | }); 36 | -------------------------------------------------------------------------------- /packages/bunnygram/src/adapters/qstash/config.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const ZQStashSendConfig = z.object({ 4 | qstashToken: z.string({ 5 | description: 6 | "The qstash token. We try to infer from process.env, so this is optional", 7 | invalid_type_error: 8 | "Did you forget to set QSTASH_TOKEN or NEXT_PUBLIC_QSTASH_TOKEN or pass it in via config?", 9 | required_error: 10 | "Did you forget to set QSTASH_TOKEN or NEXT_PUBLIC_QSTASH_TOKEN or pass it in via config?", 11 | }), 12 | }); 13 | 14 | export const ZQStashVerifyConfig = z.object({ 15 | qstashCurrentSigningKey: z.string({ 16 | invalid_type_error: 17 | "Did you forget to set QSTASH_CURRENT_SIGNING_KEY or pass it in via config?", 18 | required_error: 19 | "Did you forget to set QSTASH_CURRENT_SIGNING_KEY or pass it in via config?", 20 | }), 21 | qstashNextSigningKey: z.string({ 22 | invalid_type_error: 23 | "Did you forget to set QSTASH_NEXT_SIGNING_KEY or pass it in via config?", 24 | required_error: 25 | "Did you forget to set QSTASH_NEXT_SIGNING_KEY or pass it in via config?", 26 | }), 27 | }); 28 | 29 | export type IQStashConfig = Partial< 30 | z.infer & z.infer 31 | >; 32 | 33 | export const getQStashSendConfig = (props?: IQStashConfig) => { 34 | const config = ZQStashSendConfig.parse({ 35 | qstashToken: getToken(props), 36 | }); 37 | return config; 38 | }; 39 | 40 | export const getQStashVerifyConfig = (props?: IQStashConfig) => { 41 | const config = ZQStashVerifyConfig.parse({ 42 | qstashCurrentSigningKey: getCurrentSigningKey(props), 43 | qstashNextSigningKey: getNextSigningKey(props), 44 | }); 45 | return config; 46 | }; 47 | 48 | export const getToken = (props?: IQStashConfig) => { 49 | return ( 50 | props?.qstashToken ?? 51 | process.env.QSTASH_TOKEN ?? 52 | process.env.NEXT_PUBLIC_QSTASH_TOKEN 53 | ); 54 | }; 55 | 56 | export const getCurrentSigningKey = (props?: IQStashConfig) => { 57 | return ( 58 | props?.qstashCurrentSigningKey ?? process.env.QSTASH_CURRENT_SIGNING_KEY 59 | ); 60 | }; 61 | 62 | export const getNextSigningKey = (props?: IQStashConfig) => { 63 | return props?.qstashNextSigningKey ?? process.env.QSTASH_NEXT_SIGNING_KEY; 64 | }; 65 | -------------------------------------------------------------------------------- /packages/bunnygram/src/adapters/qstash/index.ts: -------------------------------------------------------------------------------- 1 | import { Client, Receiver, type PublishJsonRequest } from "@upstash/qstash"; 2 | import type { IAdapter } from "../types"; 3 | import { 4 | getQStashSendConfig, 5 | getQStashVerifyConfig, 6 | type IQStashConfig, 7 | } from "./config"; 8 | 9 | export interface IQStashAdapterProps { 10 | /** 11 | * override the qstash.publishJSON method 12 | */ 13 | sendOptions?: Omit; 14 | 15 | /** 16 | * env vars that need to be set 17 | */ 18 | config?: IQStashConfig; 19 | } 20 | 21 | export const QStashAdapter = (props: IQStashAdapterProps): IAdapter => { 22 | const { config, sendOptions } = props; 23 | 24 | return { 25 | send: async (sendProps) => { 26 | const sendConfig = getQStashSendConfig(config); 27 | 28 | let qstashClient: Client; 29 | try { 30 | qstashClient = new Client({ 31 | token: sendConfig.qstashToken, 32 | }); 33 | } catch (e) { 34 | console.error(e); 35 | return { 36 | error: true, 37 | message: 38 | e instanceof Error 39 | ? e.message 40 | : "Failed to initialize qstash client", 41 | }; 42 | } 43 | 44 | const { payload, url } = sendProps; 45 | 46 | let messageId: string; 47 | try { 48 | const response = await qstashClient.publishJSON({ 49 | url, 50 | body: payload, 51 | ...sendOptions, 52 | }); 53 | messageId = response.messageId; 54 | } catch (e) { 55 | console.error(e); 56 | return { 57 | error: true, 58 | message: e instanceof Error ? e.message : "qstash.publishJSON failed", 59 | }; 60 | } 61 | 62 | return { 63 | error: false, 64 | message: "POST to QStash succeeded", 65 | messageId, 66 | }; 67 | }, 68 | verify: async (verifyProps) => { 69 | const { req, rawBody, runtime } = verifyProps; 70 | if (runtime === "browser") { 71 | return { 72 | verified: true, 73 | }; 74 | } 75 | 76 | const verifyConfig = getQStashVerifyConfig(); 77 | 78 | const receiver = new Receiver({ 79 | currentSigningKey: verifyConfig.qstashCurrentSigningKey, 80 | nextSigningKey: verifyConfig.qstashNextSigningKey, 81 | }); 82 | 83 | const signature = req.headers.get("upstash-signature"); 84 | 85 | if (!signature) { 86 | return { 87 | verified: false, 88 | message: "`Upstash-Signature` header is missing", 89 | }; 90 | } 91 | 92 | if (typeof signature !== "string") { 93 | return { 94 | verified: false, 95 | message: "`Upstash-Signature` header is not a string", 96 | }; 97 | } 98 | 99 | const isValid = await receiver.verify({ 100 | signature, 101 | body: rawBody, 102 | }); 103 | 104 | if (!isValid) { 105 | return { 106 | verified: false, 107 | message: "Invalid signature", 108 | }; 109 | } 110 | 111 | return { 112 | verified: true, 113 | }; 114 | }, 115 | }; 116 | }; 117 | -------------------------------------------------------------------------------- /packages/bunnygram/src/adapters/types.ts: -------------------------------------------------------------------------------- 1 | import type { IRuntime } from "../scheduler/config"; 2 | import type { IErrorResponse } from "../utilities/types"; 3 | 4 | // ----- adapter 5 | 6 | export interface IAdapter { 7 | /** 8 | * Verifies the signature of the incoming request 9 | */ 10 | verify: IAdapterVerify; 11 | 12 | /** 13 | * Sends the payload to the backend 14 | */ 15 | send: IAdapterSend; 16 | } 17 | 18 | // ----- adapter verify 19 | 20 | export type IAdapterVerify = ( 21 | props: IAdapterVerifyProps 22 | ) => Promise; 23 | 24 | export interface IAdapterVerifyProps { 25 | req: Request; 26 | rawBody: string; 27 | runtime: IRuntime; 28 | } 29 | 30 | export interface IAdapterVerifyReturnValue { 31 | verified: boolean; 32 | message?: string; 33 | } 34 | 35 | // ----- adapter send 36 | 37 | export type IAdapterSend = ( 38 | props: IAdapterSendProps 39 | ) => Promise; 40 | 41 | export interface IAdapterSendProps { 42 | /** 43 | * which URL to send request to 44 | */ 45 | url: string; 46 | 47 | /** 48 | * the payload that will eventually reach the receive handler 49 | */ 50 | payload: JP; 51 | 52 | /** 53 | * What runtime the send() is running under 54 | */ 55 | runtime: IRuntime; 56 | } 57 | 58 | export interface IAdapterSendReturnValue extends IErrorResponse { 59 | messageId?: string; 60 | } 61 | -------------------------------------------------------------------------------- /packages/bunnygram/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./scheduler"; 2 | export { BasicAdapter, QStashAdapter, type IAdapter } from "./adapters"; 3 | -------------------------------------------------------------------------------- /packages/bunnygram/src/scheduler/config/config.test.ts: -------------------------------------------------------------------------------- 1 | import { run } from "node:test"; 2 | import { test, expect, vi } from "vitest"; 3 | import { getBaseUrl, getRuntime } from "."; 4 | 5 | test("getBaseUrl with env", () => { 6 | vi.stubEnv("VERCEL_URL", "example.vercel.app"); 7 | let baseUrl = getBaseUrl(); 8 | expect(baseUrl).toBe("https://example.vercel.app"); 9 | 10 | vi.unstubAllEnvs(); 11 | vi.stubEnv("NEXT_PUBLIC_VERCEL_URL", "world.vercel.app"); 12 | baseUrl = getBaseUrl(); 13 | expect(baseUrl).toBe("https://world.vercel.app"); 14 | }); 15 | 16 | test("getRuntime with env", () => { 17 | vi.stubEnv("NEXT_RUNTIME", "edge"); 18 | let runtime = getRuntime(); 19 | expect(run).toBe("edge"); 20 | 21 | vi.unstubAllEnvs(); 22 | vi.stubEnv("NEXT_RUNTIME", "nodejs"); 23 | runtime = getRuntime(); 24 | expect(runtime).toBe("nodejs"); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/bunnygram/src/scheduler/config/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import type { IAdapter } from "../../adapters"; 3 | 4 | export type IRuntime = "nodejs" | "edge" | "browser"; 5 | 6 | export interface IConfig { 7 | route: string; 8 | adapter?: IAdapter; 9 | baseUrl?: string; 10 | validator?: z.ZodSchema; 11 | } 12 | 13 | export const makeConfig = ( 14 | config: IConfig 15 | ): IConfig => { 16 | return config; 17 | }; 18 | 19 | export const getBaseUrl = () => { 20 | if (process.env.VERCEL_URL) { 21 | return `https://${process.env.VERCEL_URL}`; 22 | } 23 | 24 | if (process.env.NEXT_PUBLIC_VERCEL_URL) { 25 | return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`; 26 | } 27 | }; 28 | 29 | export const getRuntime = (): IRuntime => { 30 | // neither node nor edge support window 31 | if (typeof window !== "undefined") { 32 | return "browser"; 33 | } 34 | 35 | // this variable should be populated for edge 36 | if (process.env.NEXT_RUNTIME === "edge") { 37 | return "edge"; 38 | } 39 | 40 | // default to nodejs 41 | return "nodejs"; 42 | }; 43 | -------------------------------------------------------------------------------- /packages/bunnygram/src/scheduler/index.ts: -------------------------------------------------------------------------------- 1 | export { makeConfig, type IConfig } from "./config"; 2 | export { onReceive } from "./receive"; 3 | export { send } from "./send"; 4 | -------------------------------------------------------------------------------- /packages/bunnygram/src/scheduler/intellisense-test.ts: -------------------------------------------------------------------------------- 1 | // send 2 | 3 | import { BasicAdapter } from "../adapters"; 4 | import { IConfig, makeConfig } from "./config"; 5 | import { onReceive } from "./receive"; 6 | import { send } from "./send"; 7 | 8 | // ----- test 9 | 10 | interface TestJP { 11 | hello: string; 12 | } 13 | 14 | interface TestJR { 15 | world: string; 16 | } 17 | 18 | const config: IConfig = { 19 | route: "/example", 20 | }; 21 | 22 | const c = makeConfig({ 23 | route: "/example", 24 | adapter: BasicAdapter(), 25 | }); 26 | 27 | onReceive({ 28 | config: c, 29 | job: async ({ payload }) => { 30 | return { 31 | world: payload.hello, 32 | }; 33 | }, 34 | }); 35 | 36 | send({ 37 | config: c, 38 | payload: { 39 | hello: "goodbye", 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /packages/bunnygram/src/scheduler/messages.ts: -------------------------------------------------------------------------------- 1 | import type { IReceiveReturnValue } from "./receive/types"; 2 | 3 | export const statusMessages = { 4 | "err-post-only": { 5 | msg: { 6 | message: "Only POST requests allowed", 7 | error: true, 8 | }, 9 | httpStatusCode: 405, 10 | }, 11 | "err-adapter-verify": { 12 | msg: { 13 | message: "Adapter could not verify request", 14 | error: true, 15 | }, 16 | httpStatusCode: 500, 17 | }, 18 | "err-validate-payload": { 19 | msg: { 20 | message: "Failed to validate request payload", 21 | error: true, 22 | }, 23 | httpStatusCode: 500, 24 | }, 25 | "err-job-run": { 26 | msg: { 27 | message: "Job failed to run", 28 | error: true, 29 | }, 30 | httpStatusCode: 500, 31 | }, 32 | "success-job-run": { 33 | msg: { 34 | message: "Job finishing running", 35 | error: false, 36 | }, 37 | httpStatusCode: 200, 38 | }, 39 | } satisfies Record< 40 | string, 41 | { 42 | msg: IReceiveReturnValue; 43 | httpStatusCode: number; 44 | } 45 | >; 46 | -------------------------------------------------------------------------------- /packages/bunnygram/src/scheduler/receive/index.ts: -------------------------------------------------------------------------------- 1 | import { BasicAdapter } from "../../adapters"; 2 | import { getFetchRequestBody } from "../../utilities/http"; 3 | import { getRuntime } from "../config"; 4 | import { statusMessages } from "../messages"; 5 | import type { IHandler, IReceiveProps } from "./types"; 6 | 7 | export const onReceive = ( 8 | props: IReceiveProps 9 | ): IHandler => { 10 | const { job, config } = props; 11 | const { adapter = BasicAdapter(), validator } = config; 12 | const runtime = getRuntime(); 13 | 14 | return async (req: Request): Promise => { 15 | // ----- check request method 16 | 17 | if (req.method !== "POST") { 18 | return resJson("err-post-only"); 19 | } 20 | 21 | // ----- get the parsed and raw body from the request, while leaving the 22 | // original unchanged 23 | const { parsedBody, rawBody } = await getFetchRequestBody(req); 24 | 25 | // ----- verify the request 26 | 27 | const verification = await adapter.verify({ 28 | req, 29 | rawBody, 30 | runtime, 31 | }); 32 | 33 | if (!verification.verified) { 34 | return resJson("err-adapter-verify"); 35 | } 36 | 37 | // ----- validate the payload 38 | 39 | let payload: JP = parsedBody!; 40 | 41 | if (validator) { 42 | const p = validator.safeParse(payload); 43 | if (!p.success) { 44 | console.error(p.error); 45 | return resJson("err-validate-payload"); 46 | } 47 | } 48 | 49 | // ----- run the job 50 | 51 | try { 52 | const jobResponse = await job({ 53 | payload, 54 | req, 55 | }); 56 | return resJson("success-job-run", jobResponse); 57 | } catch (err) { 58 | console.error(err); 59 | return resJson("err-job-run"); 60 | } 61 | }; 62 | }; 63 | 64 | const resJson = (status: keyof typeof statusMessages, body?: any) => { 65 | return new Response( 66 | JSON.stringify( 67 | { 68 | ...(body ? { jobResponse: body } : {}), 69 | ...statusMessages[status].msg, 70 | }, 71 | null, 72 | 2 73 | ), 74 | { 75 | status: statusMessages[status].httpStatusCode, 76 | headers: { 77 | "Content-Type": "application/json", 78 | }, 79 | } 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /packages/bunnygram/src/scheduler/receive/types.ts: -------------------------------------------------------------------------------- 1 | import type { IConfig } from "../config"; 2 | 3 | export interface IReceiveProps { 4 | config: IConfig; 5 | job: (props: IJobProps) => Promise; 6 | } 7 | 8 | export interface IReceiveReturnValue { 9 | jobResponse?: JR; 10 | error: boolean; 11 | message: string; 12 | } 13 | 14 | export interface IJobProps { 15 | payload: JP; 16 | req: Request; 17 | } 18 | 19 | export type IHandler = (req: Request) => Promise; 20 | -------------------------------------------------------------------------------- /packages/bunnygram/src/scheduler/send/index.ts: -------------------------------------------------------------------------------- 1 | import { BasicAdapter, type IAdapterSendReturnValue } from "../../adapters"; 2 | import { getBaseUrl, getRuntime, type IConfig } from "../config"; 3 | 4 | export interface ISendProps { 5 | config: IConfig; 6 | payload: JP; 7 | } 8 | 9 | export const send = async ( 10 | props: ISendProps 11 | ): Promise => { 12 | const { payload, config } = props; 13 | const { 14 | route, 15 | adapter = BasicAdapter(), 16 | baseUrl = getBaseUrl(), 17 | validator, 18 | } = config; 19 | const runtime = getRuntime(); 20 | 21 | const url = new URL(route, baseUrl).href; 22 | 23 | if (validator) { 24 | const p = validator.safeParse(payload); 25 | if (!p.success) { 26 | console.error(p.error); 27 | return { 28 | error: true, 29 | message: "Could not validate payload", 30 | }; 31 | } 32 | } 33 | 34 | const response = await adapter.send({ 35 | payload, 36 | url, 37 | runtime, 38 | }); 39 | 40 | return response; 41 | }; 42 | -------------------------------------------------------------------------------- /packages/bunnygram/src/utilities/http/index.ts: -------------------------------------------------------------------------------- 1 | export const getFetchRequestBody = async ( 2 | req: Request 3 | ): Promise<{ 4 | parsedBody: T | undefined; 5 | rawBody: string; 6 | }> => { 7 | // clone the req so we don't consume the body 8 | const clonedRequestForJson = req.clone(); 9 | const clonedRequestForText = req.clone(); 10 | 11 | // parse as json 12 | let parsedBody: T | undefined = undefined; 13 | if (clonedRequestForJson.headers.get("content-type") === "application/json") { 14 | parsedBody = (await clonedRequestForJson.json()) as T; 15 | } 16 | 17 | // parse as text 18 | const rawBody = await clonedRequestForText.text(); 19 | return { 20 | parsedBody, 21 | rawBody, 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/bunnygram/src/utilities/types.ts: -------------------------------------------------------------------------------- 1 | export interface IErrorResponse { 2 | message: string; 3 | error: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /packages/bunnygram/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | /* Projects */ 5 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 6 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 7 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 8 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 9 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 10 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 11 | /* Language and Environment */ 12 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 13 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 14 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 15 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 16 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 17 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 18 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 19 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 20 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 21 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 22 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 23 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 24 | /* Modules */ 25 | "module": "commonjs", /* Specify what module code is generated. */ 26 | "rootDir": "./src", /* Specify the root folder within your source files. */ 27 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 28 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 29 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 30 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 31 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 32 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 33 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 34 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 35 | // "resolveJsonModule": true, /* Enable importing .json files. */ 36 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 37 | /* JavaScript Support */ 38 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 39 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 40 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 41 | /* Emit */ 42 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 43 | "declarationMap": true, /* Create sourcemaps for d.ts files. */ 44 | "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 45 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 46 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 47 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 48 | // "removeComments": true, /* Disable emitting comments. */ 49 | // "noEmit": true, /* Disable emitting files from a compilation. */ 50 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 51 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 52 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 53 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 56 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 57 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 58 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 59 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 60 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 61 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 62 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 63 | // "declarationDir": "./dist", /* Specify the output directory for generated declaration files. */ 64 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 65 | /* Interop Constraints */ 66 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 67 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 68 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 69 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 70 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 71 | /* Type Checking */ 72 | "strict": true, /* Enable all strict type-checking options. */ 73 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 74 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 75 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 76 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 77 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 78 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 79 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 80 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 81 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 82 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 83 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 84 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 85 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 86 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 87 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 88 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 89 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 90 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 91 | /* Completeness */ 92 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 93 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 94 | }, 95 | "include": [ 96 | "./src" 97 | ], 98 | "exclude": [ 99 | "./tsup.config.ts" 100 | ], 101 | } -------------------------------------------------------------------------------- /packages/bunnygram/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | clean: true, 6 | format: ["esm"], 7 | dts: true, 8 | // for some strange reason, intellisense stops working when importing 9 | // bunnygram in other packages, so disabling tsc dts and using the inbuilt one 10 | // for now. We lose out on declaration maps but its no big deal onSuccess: 11 | // async () => { $`tsc`; 12 | // }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /packages/docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # docs 2 | 3 | ## 1.0.0 4 | 5 | ### Major Changes 6 | 7 | - Breaking change: 8 | 9 | This version updates bunnygram to use Fetch API and work with Next.js 13.2 route handlers inside the `app` directory. Bunnygram will no longer work with `pages` directory API routes. 10 | 11 | Why this change was made: 12 | 13 | The ecosystem seems to be moving towards the new `app` directory, and this change simplifies Bunnygram's code significantly. 14 | 15 | How you should update your code: 16 | 17 | Please consult the docs for how to update your code. The minimum required Next.js version is 13.2. The code changes are minimal (mostly moving from `pages` to `app` directory). Please open an issue if you encounter buggy behavior. Thanks! 18 | -------------------------------------------------------------------------------- /packages/docs/next.config.js: -------------------------------------------------------------------------------- 1 | const withNextra = require("nextra")({ 2 | theme: "nextra-theme-docs", 3 | themeConfig: "./theme.config.tsx", 4 | }); 5 | 6 | module.exports = withNextra(); 7 | -------------------------------------------------------------------------------- /packages/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@types/node": "18.15.13", 13 | "@types/react": "18.0.37", 14 | "@types/react-dom": "18.0.11", 15 | "@vercel/analytics": "^1.0.0", 16 | "next": "13.3.0", 17 | "nextra": "^2.4.0", 18 | "nextra-theme-docs": "^2.4.0", 19 | "react": "18.2.0", 20 | "react-dom": "18.2.0", 21 | "typescript": "5.0.4" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/docs/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from 'next/app'; 2 | import { Analytics } from '@vercel/analytics/react'; 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default MyApp; 14 | -------------------------------------------------------------------------------- /packages/docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": "Getting started", 3 | "config": "Config", 4 | "adapters": "Adapters", 5 | "faq": "FAQ" 6 | } 7 | -------------------------------------------------------------------------------- /packages/docs/pages/adapters/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "basic": "Basic", 3 | "qstash": "QStash", 4 | "custom": "Make your own" 5 | } 6 | -------------------------------------------------------------------------------- /packages/docs/pages/adapters/basic.mdx: -------------------------------------------------------------------------------- 1 | # Basic adapter 2 | 3 | The basic adapter is the **default** adapter when none is specified. It is intended for testing purposes only (e.g. on `localhost`, or when your app is not hosted on a publicly accessible URL). 4 | 5 | The basic adapter is simply a request forwarder. The `send()` will just make a direct `POST` request to `onReceive()` at the specified route. 6 | 7 | Using the [QStash adapter](/adapters/qstash) is recommended instead, so that you can control retries, delays etc. 8 | 9 | ## Using the basic adapter 10 | 11 | Put your scheduler config somewhere e.g. `tasks/send-email.ts`: 12 | 13 | ```ts 14 | // tasks/send-email.ts 15 | 16 | import { makeConfig, BasicAdapter } from "bunnygram"; 17 | 18 | interface JobPayload { 19 | emailAddress: string; 20 | } 21 | 22 | interface JobResponse { 23 | status: boolean; 24 | } 25 | 26 | export const sendEmail = makeConfig({ 27 | route: "/api/send-email", 28 | adapter: BasicAdapter(), 29 | }); 30 | ``` 31 | 32 | And you're done! All other steps should be the same as in [Getting started](/). 33 | 34 | ### Conditionally using the basic adapter on `localhost` 35 | 36 | If your app is on `localhost` or does not have a publicly accessible URL, you may wish to conditionally use the basic adapter: 37 | 38 | ```ts 39 | // tasks/send-email.ts 40 | 41 | import { makeConfig, BasicAdapter, QStashAdapter } from "bunnygram"; 42 | 43 | interface JobPayload { 44 | emailAddress: string; 45 | } 46 | 47 | interface JobResponse { 48 | status: boolean; 49 | } 50 | 51 | export const sendEmail = makeConfig({ 52 | route: "/api/send-email", 53 | baseUrl: 54 | process.env.NODE_ENV === "development" 55 | ? "http://localhost:3000" 56 | : undefined, // 👈 leave undefined if you still want Bunnygram to guess your `baseUrl` in production 57 | adapter: 58 | process.env.NODE_ENV === "development" ? BasicAdapter() : QStashAdapter(), 59 | }); 60 | ``` 61 | -------------------------------------------------------------------------------- /packages/docs/pages/adapters/custom.mdx: -------------------------------------------------------------------------------- 1 | # Make your own 2 | 3 | Making your own adapter is straightforward. An adapter must implement the following interface: 4 | 5 | ```ts 6 | interface IAdapter { 7 | /** 8 | * Verifies the signature of the incoming request 9 | */ 10 | verify: IAdapterVerify; 11 | 12 | /** 13 | * Sends the payload to the backend 14 | */ 15 | send: IAdapterSend; 16 | } 17 | ``` 18 | 19 | Here, `JP` is a generic which refers to the type of the job payload. You can use the existing adapters on GitHub as a guide. Otherwise, here is a minimal reference: 20 | 21 | ```ts 22 | import type { IAdapter } from "bunnygram"; 23 | 24 | const MyAdapter = (): IAdapter => { 25 | return { 26 | verify: async (verifyProps) => { 27 | const { rawBody, req, runtime } = verifyProps; 28 | return { 29 | verified: true, 30 | message: "LGTM 👍", 31 | }; 32 | }, 33 | send: async (sendProps) => { 34 | const { payload, url, runtime } = sendProps; 35 | console.log(payload, url); 36 | }, 37 | }; 38 | }; 39 | ``` 40 | 41 | ## Runtimes 42 | 43 | Your adapter should be resilient to running in different JavaScript runtimes e.g. browser, Edge or Node.js. 44 | 45 | Here are some tips you can use: 46 | 47 | 1. Bunnygram exposes the current `runtime` as part of the `sendProps` on the ` send` function and `verifyProps` on the `verify` function. You can use that for conditional logic. 48 | 2. Even if you have conditional guards, you can still run into import errors if, for example, you have a Node.js-specific dependency and the import statement is read by the browser (or any other incompatible import combination between the runtimes). To prevent this, you can use [runtime dynamic imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import). 49 | -------------------------------------------------------------------------------- /packages/docs/pages/adapters/qstash.mdx: -------------------------------------------------------------------------------- 1 | # QStash adapter 2 | 3 | QStash is the recommended adapter for use with Bunnygram. You can find out more about QStash on https://docs.upstash.com/qstash. Using QStash, you can configure retries, delays, cron and more. 4 | 5 | This adapter wraps https://github.com/upstash/sdk-qstash-ts. Many thanks to the Upstash developers for building it! 6 | 7 | ## Using the QStash adapter 8 | 9 | Put your scheduler config somewhere e.g. `tasks/send-email.ts`: 10 | 11 | ```ts 12 | // tasks/send-email.ts 13 | 14 | import { makeConfig, QStashAdapter } from "bunnygram"; 15 | 16 | interface JobPayload { 17 | emailAddress: string; 18 | } 19 | 20 | interface JobResponse { 21 | status: boolean; 22 | } 23 | 24 | export const sendEmail = makeConfig({ 25 | route: "/api/send-email", 26 | adapter: QStashAdapter({}), 27 | }); 28 | ``` 29 | 30 | ## Required config 31 | 32 | QStash requires 3 environment variables to be set: 33 | 34 | - `QSTASH_TOKEN` 35 | - `QSTASH_CURRENT_SIGNING_KEY` 36 | - `QSTASH_NEXT_SIGNING_KEY` 37 | 38 | You can find these in your QStash console. It is recommended to set them in your `.env.local` for local development, and on your hosting provider's dashboard for production deployments. 39 | 40 | Alternatively, you can also pass the config in code: 41 | 42 | ```ts 43 | export const sendEmail = makeConfig({ 44 | route: "/api/send-email", 45 | adapter: QStashAdapter({ 46 | config: { 47 | qstashCurrentSigningKey: "", 48 | qstashNextSigningKey: "", 49 | qstashToken: "", 50 | }, 51 | }), 52 | }); 53 | ``` 54 | 55 | ## Send options 56 | 57 | You can also set send/publish options for the underlying QStash SDK e.g. retries, delays. You can learn more about what each option does in the QStash docs https://docs.upstash.com/qstash. 58 | 59 | ```ts 60 | export const sendEmail = makeConfig({ 61 | route: "/api/send-email", 62 | adapter: QStashAdapter({ 63 | sendOptions: { 64 | callback, 65 | contentBasedDeduplication, 66 | cron, 67 | deduplicationId, 68 | delay, 69 | headers, 70 | notBefore, 71 | retries, 72 | topic, 73 | url, 74 | }, 75 | }), 76 | }); 77 | ``` 78 | -------------------------------------------------------------------------------- /packages/docs/pages/config.mdx: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | This page goes over the configuration options for the `makeConfig` function. 4 | 5 | ```ts 6 | import { makeConfig } from "bunnygram"; 7 | 8 | interface JobPayload {} 9 | interface JobResponse {} 10 | 11 | export const schedulerConfig = makeConfig({ 12 | route, 13 | baseUrl, 14 | adapter, 15 | validator, 16 | }); 17 | ``` 18 | 19 | ## Generics 20 | 21 | The `makeConfig` function is generic. You can specify the structure of your job's payload and response (in that order) as TypeScript types and pass them to it. This gives you Intellisense in the `send()` and `onReceive()` functions. 22 | 23 | ## `route` 24 | 25 | This is a required property. This helps both the `send()` and `onReceive()` functions communicate with each other. This is the relative path to a route handler e.g. `/api/send-email` or `/api/do-some-expensive-task`. 26 | 27 | Note that: `baseUrl` + `route` = the full URL that `send()` will send your payload to. More on `baseUrl` below. 28 | 29 | ## `baseUrl` 30 | 31 | If your app is hosted on Vercel, Bunnygram will try to guess your `baseUrl`. Otherwise if your app is deployed to another host, or you are testing on `localhost`, you should set the `baseUrl` manually in the config. You might want to set the URL conditionally in dev/prod based on `process.env`. You can see an example of how to do that in the [Basic adapter](/adapters/basic) docs. 32 | 33 | Note that most adapters (except for the Basic adapter) won't work when testing on `localhost`, since these services have no way of talking to your `localhost`. To overcome this limitation, you can use `ngrok`, `tailscale` etc. which can help expose your computer to the internet. 34 | 35 | ## `adapter` 36 | 37 | Which adapter you'd like to use with Bunnygram. By default this is the [Basic adapter](/adapters/basic). We recommend using another adapter instead. You can consult the adapter docs to find one that meets your use case. 38 | 39 | ## `validator` 40 | 41 | You can add runtime type safety by providing a Zod validator to the config, like this: 42 | 43 | ```ts 44 | // tasks/send-email.ts 45 | 46 | import { z } from "zod"; 47 | import { makeConfig } from "bunnygram"; 48 | 49 | const JobPayloadSchema = z.object({ 50 | emailAddress: z.string(), 51 | emailBody: z.string(), 52 | }); 53 | 54 | interface JobResponse { 55 | status: boolean; 56 | } 57 | 58 | export const sendEmail = makeConfig< 59 | z.infer, 60 | JobResponse 61 | >({ 62 | route: "/api/send-email", 63 | validator: JobPayloadSchema, 64 | }); 65 | ``` 66 | 67 | The validator will be run inside both `send()` and `onReceive()` to make sure that the received data conforms to the expected type. 68 | -------------------------------------------------------------------------------- /packages/docs/pages/faq.mdx: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ### How many times can I call my job? 4 | 5 | As many times as the adapter's service allows. For e.g. QStash has their [pricing available here](https://upstash.com/#section-pricing). 6 | 7 | ### How long can my job run for? 8 | 9 | Your job is run inside a Next.js route handler. 10 | 11 | If you deployed your Next.js app to a "serverful" provider e.g. AWS, Render or Railway, these routes usually don't have any hard timeout beyond that which you set. 12 | 13 | If you deployed your Next.js app to a "serverless" provider e.g. Vercel or Netlify, these routes usually have a timeout, usually a couple of minutes. You can consult their documentation for the exact numbers. 14 | 15 | ### How does Bunnygram work? 16 | 17 | Conceptually, Bunnygram is straighforward. It instantiates a Next.js route handler and another function to invoke it. It abstracts away some of the boilerplate regarding parsing request bodies, providing type safety and so on. 18 | 19 | The flow of data is as follows. The `send()` function (introduced in other pages of these docs) sends a `POST` request with your payload to your adapter's backend e.g. QStash. That backend will then forward that payload by sending another `POST` request to the route handler. The handler will receive the data in `onReceive()` and run your job with it. 20 | -------------------------------------------------------------------------------- /packages/docs/pages/index.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from "nextra-theme-docs"; 2 | 3 | # Bunnygram 🐇📬 4 | 5 | Simple task scheduling for Next.js 6 | 7 | 8 | If you find inaccuracies or anything missing, please open an issue on GitHub! 9 | 10 | 11 |
12 | 13 | 20 | 21 | > Cover from Midjourney 22 | 23 | ## Introduction 24 | 25 | Bunnygram is a task scheduler for Next.js. You might need it if you want to defer a computationally expensive or long-running task in the background. 26 | 27 | You can choose from multiple adapters, including [QStash](/adapters/qstash), with more coming soon. 28 | 29 | It borrows from patterns introduced by a similar library [Quirrel](https://github.com/quirrel-dev/quirrel), which was one of the first to make adding task/job queues to Next.js more developer friendly. 30 | 31 | ## Install 32 | 33 | ```sh 34 | pnpm add bunnygram 35 | ``` 36 | 37 | You can also use `yarn` or `npm`. 38 | 39 | Note that Bunnygram is 1) ESM only and 2) works with Next.js 13.2 or later (which introduces route handlers). 40 | 41 | ## Setup 42 | 43 | Suppose you wanted to send your user an email when they signed up. Here's how you can kick off the email job in the background with Bunnygram: 44 | 45 | ### Step 1: Define your scheduler 46 | 47 | Put your scheduler config somewhere e.g. `tasks/send-email.ts`: 48 | 49 | ```ts 50 | // tasks/send-email.ts 51 | 52 | import { makeConfig } from "bunnygram"; 53 | 54 | interface JobPayload { 55 | emailAddress: string; 56 | } 57 | 58 | interface JobResponse { 59 | status: boolean; 60 | } 61 | 62 | export const sendEmail = makeConfig({ 63 | // where the route handler will be accessible on 64 | route: "/api/send-email", 65 | 66 | // if you're just testing on localhost, you also need to supply the baseUrl here: 67 | // baseUrl: "http://localhost:3000", 68 | 69 | // the default adapter is BasicAdapter, but you can specify the one you want 70 | // adapter: BasicAdapter() 71 | }); 72 | ``` 73 | 74 | You can find out more about the [config here](/config). 75 | 76 | ### Step 2: Set up a Next.js route handler 77 | 78 | ```ts 79 | // app/api/send-email/route.ts 80 | 81 | import { onReceive } from "bunnygram"; 82 | import { sendEmail } from "@/tasks/send-email"; 83 | import { mailchimp } from "example-email-api"; 84 | 85 | export const POST = onReceive({ 86 | config: sendEmail, 87 | // the job to run 88 | // autocomplete 👇 on the payload 89 | job: async ({ payload }) => { 90 | const { emailAddress } = payload; 91 | await mailchimp.send({ 92 | // ... use the payload 93 | }); 94 | // 👇 autocomplete on the response 95 | return { 96 | status: true, 97 | }; 98 | }, 99 | }); 100 | ``` 101 | 102 | ### Step 3: Send a message 103 | 104 | Send a message to the receiver from anywhere else inside our Next.js app: 105 | 106 | ```tsx 107 | // src/pages/index.tsx 108 | 109 | import { send } from "bunnygram"; 110 | import { sendEmail } from "@/tasks/send-email"; 111 | 112 | export default function Home() { 113 | const runJob = async () => { 114 | await send({ 115 | config: sendEmail, 116 | // 👇 autocomplete on the payload 117 | payload: { 118 | emailAddress: "hello@gmail.com", 119 | }, 120 | }); 121 | }; 122 | 123 | return ; 124 | } 125 | ``` 126 | -------------------------------------------------------------------------------- /packages/docs/public/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarimabbas/bunnygram/1a7489772c4bd2839da7737f8272276b6d4b3461/packages/docs/public/cover.png -------------------------------------------------------------------------------- /packages/docs/theme.config.tsx: -------------------------------------------------------------------------------- 1 | import { DocsThemeConfig } from "nextra-theme-docs"; 2 | 3 | const config: DocsThemeConfig = { 4 | logo: Bunnygram 🐇📬, 5 | useNextSeoProps() { 6 | return { 7 | titleTemplate: "%s – Bunnygram", 8 | }; 9 | }, 10 | head: ( 11 | <> 12 | 13 | 14 | 18 | 22 | 23 | ), 24 | footer: { 25 | text:

MIT {new Date().getFullYear()} © Bunnygram.

, 26 | }, 27 | project: { 28 | link: "https://github.com/sarimabbas/bunnygram", 29 | }, 30 | docsRepositoryBase: 31 | "https://github.com/sarimabbas/bunnygram/tree/main/packages/docs", 32 | }; 33 | 34 | export default config; 35 | -------------------------------------------------------------------------------- /packages/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "baseUrl": ".", 22 | "paths": { 23 | "@/*": [ 24 | "./*" 25 | ] 26 | } 27 | }, 28 | "include": [ 29 | "next-env.d.ts", 30 | "**/*.ts", 31 | "**/*.tsx", 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } -------------------------------------------------------------------------------- /packages/nextjs-example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /packages/nextjs-example/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "../../node_modules/.pnpm/typescript@5.0.4/node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /packages/nextjs-example/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # bunnygram-test 2 | 3 | ## 1.0.0 4 | 5 | ### Major Changes 6 | 7 | - Breaking change: 8 | 9 | This version updates bunnygram to use Fetch API and work with Next.js 13.2 route handlers inside the `app` directory. Bunnygram will no longer work with `pages` directory API routes. 10 | 11 | Why this change was made: 12 | 13 | The ecosystem seems to be moving towards the new `app` directory, and this change simplifies Bunnygram's code significantly. 14 | 15 | How you should update your code: 16 | 17 | Please consult the docs for how to update your code. The minimum required Next.js version is 13.2. The code changes are minimal (mostly moving from `pages` to `app` directory). Please open an issue if you encounter buggy behavior. Thanks! 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies 22 | - bunnygram@2.0.0 23 | -------------------------------------------------------------------------------- /packages/nextjs-example/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 18 | 19 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 20 | 21 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 22 | 23 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 24 | 25 | ## Learn More 26 | 27 | To learn more about Next.js, take a look at the following resources: 28 | 29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 31 | 32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 33 | 34 | ## Deploy on Vercel 35 | 36 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 37 | 38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 39 | -------------------------------------------------------------------------------- /packages/nextjs-example/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | experimental: { 5 | appDir: true, 6 | }, 7 | }; 8 | 9 | module.exports = nextConfig; 10 | -------------------------------------------------------------------------------- /packages/nextjs-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bunnygram-test", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@types/node": "18.15.13", 13 | "@types/react": "18.0.37", 14 | "@types/react-dom": "18.0.11", 15 | "@upstash/qstash": "^0.3.6", 16 | "bunnygram": "workspace:*", 17 | "next": "13.3.0", 18 | "react": "18.2.0", 19 | "react-dom": "18.2.0", 20 | "typescript": "5.0.4", 21 | "zod": "^3.21.4" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/nextjs-example/src/app/example-route/route.ts: -------------------------------------------------------------------------------- 1 | import { sendEmail } from "@/tasks/send-email"; 2 | import { onReceive } from "bunnygram"; 3 | 4 | export const POST = onReceive({ 5 | config: sendEmail, 6 | job: async (props) => { 7 | console.log({ fromSend: props }); 8 | return { 9 | status: true, 10 | }; 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/nextjs-example/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: 'Next.js', 3 | description: 'Generated by Next.js', 4 | } 5 | 6 | export default function RootLayout({ 7 | children, 8 | }: { 9 | children: React.ReactNode 10 | }) { 11 | return ( 12 | 13 | {children} 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /packages/nextjs-example/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { sendEmail } from "@/tasks/send-email"; 4 | import { send } from "bunnygram"; 5 | 6 | const Page = () => { 7 | const runJob = async () => { 8 | const resp = await send({ 9 | config: sendEmail, 10 | payload: { 11 | name: "sarim", 12 | }, 13 | }); 14 | console.log({ fromReceive: resp }); 15 | }; 16 | 17 | return ( 18 |
19 | 20 |
21 | ); 22 | }; 23 | 24 | export default Page; 25 | -------------------------------------------------------------------------------- /packages/nextjs-example/src/tasks/send-email.ts: -------------------------------------------------------------------------------- 1 | import { BasicAdapter, QStashAdapter, makeConfig } from "bunnygram"; 2 | 3 | interface JobPayload { 4 | name: string; 5 | } 6 | 7 | interface JobResponse { 8 | status: boolean; 9 | } 10 | 11 | export const sendEmail = makeConfig({ 12 | route: "/example-route", 13 | baseUrl: 14 | process.env.NODE_ENV === "development" 15 | ? "http://localhost:3000" 16 | : undefined, 17 | adapter: QStashAdapter({}), 18 | }); 19 | -------------------------------------------------------------------------------- /packages/nextjs-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | }, 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ] 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' --------------------------------------------------------------------------------