├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── docker-compose.yml ├── functions ├── _shared │ └── cors.ts ├── main │ ├── ai-plugins.json │ └── index.ts └── chatgpt-plugin │ ├── openapi.json │ └── index.ts ├── scripts └── generate-openapi-spec.ts ├── fly.toml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .env -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true 4 | } 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/supabase/edge-runtime:v1.2.18 2 | 3 | COPY ./functions /home/deno/functions 4 | CMD [ "start", "--main-service", "/home/deno/functions/main" ] 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | web: 4 | build: . 5 | volumes: 6 | - type: bind 7 | source: ./functions 8 | target: /home/deno/functions 9 | ports: 10 | - "8000:9000" 11 | -------------------------------------------------------------------------------- /functions/_shared/cors.ts: -------------------------------------------------------------------------------- 1 | // CORS headers currently needed for Chrome. Not needed on Firefox. 2 | export const corsHeaders = { 3 | "Access-Control-Allow-Origin": "https://chat.openai.com", 4 | "Access-Control-Allow-Credentials": "true", 5 | "Access-Control-Allow-Private-Network": "true", 6 | "Access-Control-Allow-Headers": "*", 7 | }; 8 | -------------------------------------------------------------------------------- /functions/main/ai-plugins.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema_version": "v1", 3 | "name_for_human": "TODO Plugin (no auth)", 4 | "name_for_model": "todo", 5 | "description_for_human": "Plugin for managing a TODO list, you can add, remove and view your TODOs.", 6 | "description_for_model": "Plugin for managing a TODO list, you can add, remove and view your TODOs.", 7 | "auth": { 8 | "type": "none" 9 | }, 10 | "api": { 11 | "type": "openapi", 12 | "url": "http://localhost:8000/chatgpt-plugin/openapi.json", 13 | "is_user_authenticated": false 14 | }, 15 | "logo_url": "https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/supabase-brand-assets/logos/supabase-logo-icon.png", 16 | "contact_email": "support@example.com", 17 | "legal_info_url": "https://example.com/legal" 18 | } 19 | -------------------------------------------------------------------------------- /scripts/generate-openapi-spec.ts: -------------------------------------------------------------------------------- 1 | import swaggerJsdoc from "npm:swagger-jsdoc@6.2.8"; 2 | 3 | const options = { 4 | definition: { 5 | openapi: "3.0.1", 6 | info: { 7 | title: "TODO Plugin", 8 | description: `A plugin that allows the user to create and manage a TODO list using ChatGPT. If you do not know the user's username, ask them first before making queries to the plugin. Otherwise, use the username "global".`, 9 | version: "1.0.0", 10 | }, 11 | servers: [{ url: "http://localhost:8000" }], 12 | }, 13 | apis: ["./functions/chatgpt-plugin/index.ts"], // files containing annotations as above 14 | }; 15 | 16 | const openapiSpecification = swaggerJsdoc(options); 17 | const openapiString = JSON.stringify(openapiSpecification, null, 2); 18 | const encoder = new TextEncoder(); 19 | const data = encoder.encode(openapiString); 20 | await Deno.writeFile("./functions/chatgpt-plugin/openapi.json", data); 21 | console.log(openapiString); 22 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for supa-edge-demo on 2023-04-11T09:48:10+10:00 2 | 3 | app = "supa-chatgpt-plugin-deno-demo" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | primary_region = "syd" 7 | processes = [] 8 | 9 | [env] 10 | 11 | [experimental] 12 | auto_rollback = true 13 | 14 | [[services]] 15 | internal_port = 9000 16 | processes = ["app"] 17 | protocol = "tcp" 18 | script_checks = [] 19 | [services.concurrency] 20 | hard_limit = 25 21 | soft_limit = 20 22 | type = "connections" 23 | 24 | [[services.http_checks]] 25 | grace_period = "5s" 26 | interval = 10000 27 | method = "get" 28 | path = "/_internal/health" 29 | protocol = "http" 30 | restart_limit = 0 31 | timeout = 2000 32 | tls_skip_verify = true 33 | 34 | [[services.ports]] 35 | force_https = false 36 | handlers = ["http"] 37 | port = 80 38 | 39 | [[services.ports]] 40 | handlers = ["tls", "http"] 41 | port = 443 42 | 43 | [[services.tcp_checks]] 44 | grace_period = "1s" 45 | interval = "15s" 46 | restart_limit = 0 47 | timeout = "2s" 48 | -------------------------------------------------------------------------------- /functions/chatgpt-plugin/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.1", 3 | "info": { 4 | "title": "TODO Plugin", 5 | "description": "A plugin that allows the user to create and manage a TODO list using ChatGPT. If you do not know the user's username, ask them first before making queries to the plugin. Otherwise, use the username \"global\".", 6 | "version": "1.0.0" 7 | }, 8 | "servers": [ 9 | { 10 | "url": "http://localhost:8000" 11 | } 12 | ], 13 | "paths": { 14 | "/chatgpt-plugin/todos/{username}": { 15 | "get": { 16 | "operationId": "getTodos", 17 | "summary": "Get the list of todos", 18 | "parameters": [ 19 | { 20 | "in": "path", 21 | "name": "username", 22 | "schema": { 23 | "type": "string" 24 | }, 25 | "required": true, 26 | "description": "The name of the user." 27 | } 28 | ], 29 | "responses": { 30 | "200": { 31 | "description": "OK", 32 | "content": { 33 | "application/json": { 34 | "schema": { 35 | "$ref": "#/components/schemas/getTodosResponse" 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | }, 44 | "components": { 45 | "schemas": { 46 | "getTodosResponse": { 47 | "type": "object", 48 | "properties": { 49 | "todos": { 50 | "type": "array", 51 | "items": { 52 | "type": "string" 53 | }, 54 | "description": "The list of todos." 55 | } 56 | } 57 | } 58 | } 59 | }, 60 | "tags": [] 61 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChatGPT Plugin Template with Supabase Edge Runtime 2 | 3 | Template for building ChatGPT Plugins in TypeScript that run on [Supabase Edge Runtime](https://supabase.com/blog/edge-runtime-self-hosted-deno-functions). 4 | 5 | For a full walk through, read the blog post: [Building ChatGPT Plugins with Supabase Edge Runtime](https://supabase.com/blog/building-chatgpt-plugins-template). 6 | 7 | Or watch the video tutorial: 8 | 9 | [![video tutorial](https://img.youtube.com/vi/4pa-eEXQHJQ/0.jpg)](https://www.youtube.com/watch?v=4pa-eEXQHJQ) 10 | 11 | Note: This is a TypeScript port of OpenAI's official ["[...] simple todo list plugin with no auth" Python example](https://platform.openai.com/docs/plugins/examples). 12 | 13 | ## Generate OpenAPI spec 14 | 15 | The [`chatgpt-plugin` function](./functions/chatgpt-plugin/index.ts) contains `@openapi` [JSDoc](https://jsdoc.app/) annotations which are used for `swagger-jsdoc` to generate the [`openapi.json` file](./functions/chatgpt-plugin/openapi.json) 16 | 17 | ```bash 18 | deno run --allow-read --allow-write scripts/generate-openapi-spec.ts 19 | ``` 20 | 21 | ## Run locally 22 | 23 | - Build and start the container: `docker compose up --build` 24 | - Visit 25 | - http://localhost:8000/chatgpt-plugin 26 | - http://localhost:8000/.well-known/ai-plugin.json 27 | - http://localhost:8000/chatgpt-plugin/openapi.json 28 | - http://localhost:8000/chatgpt-plugin/todos/user 29 | 30 | File changes in the `/functions` directory will automatically be detected, except for the `/main/index.ts` function as it is a long running server. 31 | 32 | ## Deploy to Fly.io 33 | 34 | - Clone this repository. 35 | - Change `http://localhost:8000` to your Fly domain in the [`/main/ai-plugins.json` file](./functions/main/ai-plugins.json) 36 | - Open `fly.toml` and update the app name and optionally the region etc. 37 | - In your terminal, run `fly apps create` and specify the app name you just set in your `fly.toml` file. 38 | - Finally, run `fly deploy`. 39 | 40 | ## Install the Plugin in the [ChatGPT UI](https://chat.openai.com/) 41 | 42 | 1. Select the plugin model from the top drop down, then select “Plugins”, “Plugin Store”, and finally “Install an unverified plugin” or “Develop your own plugin”. 43 | 2. Either enter `localhost:8000` or your Fly domain and click "Find manifest file" 44 | -------------------------------------------------------------------------------- /functions/chatgpt-plugin/index.ts: -------------------------------------------------------------------------------- 1 | import { Application, Router } from "https://deno.land/x/oak@v11.1.0/mod.ts"; 2 | import { corsHeaders } from "../_shared/cors.ts"; 3 | import openapi from "./openapi.json" assert { type: "json" }; 4 | 5 | console.log("Hello from `chatgpt-plugin` Function!"); 6 | 7 | const _TODOS: { [key: string]: Array } = { 8 | user: ["Build your own ChatGPT Plugin!"], 9 | thor: [ 10 | "edit the video", 11 | "release the repository", 12 | "write a blogpost", 13 | "schedule a tweet", 14 | ], 15 | }; 16 | 17 | /** 18 | * @openapi 19 | * components: 20 | * schemas: 21 | * getTodosResponse: 22 | * type: object 23 | * properties: 24 | * todos: 25 | * type: array 26 | * items: 27 | * type: string 28 | * description: The list of todos. 29 | */ 30 | 31 | const router = new Router(); 32 | router 33 | .get("/chatgpt-plugin", (ctx) => { 34 | ctx.response.body = "Building ChatGPT plugins with Deno!"; 35 | }) 36 | /** 37 | * @openapi 38 | * /chatgpt-plugin/todos/{username}: 39 | * get: 40 | * operationId: getTodos 41 | * summary: Get the list of todos 42 | * parameters: 43 | * - in: path 44 | * name: username 45 | * schema: 46 | * type: string 47 | * required: true 48 | * description: The name of the user. 49 | * responses: 50 | * 200: 51 | * description: OK 52 | * content: 53 | * application/json: 54 | * schema: 55 | * $ref: '#/components/schemas/getTodosResponse' 56 | */ 57 | .get("/chatgpt-plugin/todos/:username", (ctx) => { 58 | const username = ctx.params.username.toLowerCase(); 59 | ctx.response.body = _TODOS[username] ?? []; 60 | }) 61 | .get("/chatgpt-plugin/openapi.json", (ctx) => { 62 | ctx.response.body = JSON.stringify(openapi); 63 | ctx.response.headers.set("Content-Type", "application/json"); 64 | }); 65 | 66 | const app = new Application(); 67 | // ChatGPT specific CORS headers 68 | app.use(async (ctx, next) => { 69 | await next(); 70 | let key: keyof typeof corsHeaders; 71 | for (key in corsHeaders) { 72 | ctx.response.headers.set(key, corsHeaders[key]); 73 | } 74 | }); 75 | app.use(router.routes()); 76 | app.use(router.allowedMethods()); 77 | 78 | await app.listen({ port: 8000 }); 79 | -------------------------------------------------------------------------------- /functions/main/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "https://deno.land/std@0.131.0/http/server.ts"; 2 | import * as jose from "https://deno.land/x/jose@v4.14.1/index.ts"; 3 | import { corsHeaders } from "../_shared/cors.ts"; 4 | import aiPlugins from "./ai-plugins.json" assert { type: "json" }; 5 | 6 | console.log("main function started"); 7 | 8 | const JWT_SECRET = Deno.env.get("JWT_SECRET"); 9 | const VERIFY_JWT = Deno.env.get("VERIFY_JWT") === "true"; 10 | 11 | function getAuthToken(req: Request) { 12 | const authHeader = req.headers.get("authorization"); 13 | if (!authHeader) { 14 | throw new Error("Missing authorization header"); 15 | } 16 | const [bearer, token] = authHeader.split(" "); 17 | if (bearer !== "Bearer") { 18 | throw new Error(`Auth header is not 'Bearer {token}'`); 19 | } 20 | return token; 21 | } 22 | 23 | async function verifyJWT(jwt: string): Promise { 24 | const encoder = new TextEncoder(); 25 | const secretKey = encoder.encode(JWT_SECRET); 26 | try { 27 | await jose.jwtVerify(jwt, secretKey); 28 | } catch (err) { 29 | console.error(err); 30 | return false; 31 | } 32 | return true; 33 | } 34 | 35 | serve(async (req: Request) => { 36 | if (req.method !== "OPTIONS" && VERIFY_JWT) { 37 | try { 38 | const token = getAuthToken(req); 39 | const isValidJWT = await verifyJWT(token); 40 | 41 | if (!isValidJWT) { 42 | return new Response(JSON.stringify({ msg: "Invalid JWT" }), { 43 | status: 401, 44 | headers: { "Content-Type": "application/json" }, 45 | }); 46 | } 47 | } catch (e) { 48 | console.error(e); 49 | return new Response(JSON.stringify({ msg: e.toString() }), { 50 | status: 401, 51 | headers: { "Content-Type": "application/json" }, 52 | }); 53 | } 54 | } 55 | 56 | const url = new URL(req.url); 57 | const { pathname } = url; 58 | const path_parts = pathname.split("/"); 59 | const service_name = path_parts[1]; 60 | 61 | if (!service_name || service_name === "") { 62 | const error = { msg: "missing function name in request" }; 63 | return new Response(JSON.stringify(error), { 64 | status: 400, 65 | headers: { "Content-Type": "application/json" }, 66 | }); 67 | } 68 | 69 | // Serve /.well-known/ai-plugin.json 70 | if (service_name === ".well-known") { 71 | return new Response(JSON.stringify(aiPlugins), { 72 | headers: { ...corsHeaders, "Content-Type": "application/json" }, 73 | }); 74 | } 75 | 76 | const servicePath = `/home/deno/functions/${service_name}`; 77 | console.error(`serving the request with ${servicePath}`); 78 | 79 | const memoryLimitMb = 150; 80 | const workerTimeoutMs = 1 * 60 * 1000; 81 | const noModuleCache = false; 82 | const importMapPath = null; 83 | const envVarsObj = Deno.env.toObject(); 84 | const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]); 85 | 86 | try { 87 | const worker = await EdgeRuntime.userWorkers.create({ 88 | servicePath, 89 | memoryLimitMb, 90 | workerTimeoutMs, 91 | noModuleCache, 92 | importMapPath, 93 | envVars, 94 | }); 95 | return await worker.fetch(req); 96 | } catch (e) { 97 | const error = { msg: e.toString() }; 98 | return new Response(JSON.stringify(error), { 99 | status: 500, 100 | headers: { "Content-Type": "application/json" }, 101 | }); 102 | } 103 | }); 104 | --------------------------------------------------------------------------------