├── discord-bot ├── tsconfig.json ├── .dev.vars.example ├── discord-bot.png ├── __tests__ │ └── index.test.ts ├── wrangler.toml ├── scripts │ ├── register.js │ ├── commands.js │ └── utils.js ├── src │ ├── utils.ts │ ├── index.ts │ └── types.d.ts ├── wrangler.init.toml ├── package.json ├── integrations-marketplace │ └── discord.ts └── README.md ├── public-admin-api-interfaces ├── db-service │ ├── tsconfig.json │ ├── __tests__ │ │ └── index.test.ts │ ├── wrangler.init.toml │ ├── wrangler.toml │ ├── migrations │ │ └── 0001_init.sql │ └── src │ │ └── index.ts ├── admin-service │ ├── tsconfig.json │ ├── src │ │ ├── types.d.ts │ │ ├── index.html │ │ └── index.ts │ ├── __tests__ │ │ └── index.test.ts │ └── wrangler.toml ├── public-service │ ├── tsconfig.json │ ├── src │ │ ├── types.d.ts │ │ ├── utils │ │ │ ├── html.ts │ │ │ └── escape.ts │ │ ├── user.html │ │ ├── index.html │ │ ├── index.ts │ │ └── user.ts │ ├── __tests__ │ │ └── index.test.ts │ └── wrangler.toml ├── public-admin-api-interfaces.png ├── package.json └── README.md ├── discord-bot-overview.png ├── public-admin-api-interfaces-overview.png ├── .editorconfig ├── .vscode ├── extensions.json └── settings.json ├── tsconfig.json ├── .github └── workflows │ └── semgrep.yml ├── package.json ├── README.md └── .gitignore /discord-bot/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /discord-bot/.dev.vars.example: -------------------------------------------------------------------------------- 1 | DISCORD_TOKEN= 2 | APP_ID= 3 | PUBLIC_KEY= 4 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/db-service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/admin-service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/public-service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /discord-bot-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/js-rpc-and-entrypoints-demo/HEAD/discord-bot-overview.png -------------------------------------------------------------------------------- /discord-bot/discord-bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/js-rpc-and-entrypoints-demo/HEAD/discord-bot/discord-bot.png -------------------------------------------------------------------------------- /public-admin-api-interfaces/admin-service/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.html" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/public-service/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.html" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /public-admin-api-interfaces-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/js-rpc-and-entrypoints-demo/HEAD/public-admin-api-interfaces-overview.png -------------------------------------------------------------------------------- /public-admin-api-interfaces/public-admin-api-interfaces.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/js-rpc-and-entrypoints-demo/HEAD/public-admin-api-interfaces/public-admin-api-interfaces.png -------------------------------------------------------------------------------- /public-admin-api-interfaces/public-service/src/utils/html.ts: -------------------------------------------------------------------------------- 1 | export const html = ( 2 | strings: readonly string[] | ArrayLike, 3 | ...values: unknown[] 4 | ) => String.raw({ raw: strings }, ...values); 5 | -------------------------------------------------------------------------------- /discord-bot/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from "vitest"; 2 | 3 | describe("admin-service", () => { 4 | test("it works", ({ expect }) => { 5 | // Coming soon! 6 | expect(1 + 1).toBe(2); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/db-service/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from "vitest"; 2 | 3 | describe("db-service", () => { 4 | test("it works", ({ expect }) => { 5 | // Coming soon! 6 | expect(1 + 1).toBe(2); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/admin-service/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from "vitest"; 2 | 3 | describe("admin-service", () => { 4 | test("it works", ({ expect }) => { 5 | // Coming soon! 6 | expect(1 + 1).toBe(2); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/public-service/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from "vitest"; 2 | 3 | describe("public-service", () => { 4 | test("it works", ({ expect }) => { 5 | // Coming soon! 6 | expect(1 + 1).toBe(2); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /discord-bot/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema disabled 2 | name = "discord-bot" 3 | compatibility_date = "2024-04-03" 4 | main = "./integrations-marketplace/discord.ts" 5 | 6 | [[services]] 7 | binding = "DISCORD_BOT" 8 | service = "discord-bot" 9 | entrypoint = "DiscordBot" 10 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/admin-service/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema disabled 2 | name = "private-service" 3 | compatibility_date = "2024-04-03" 4 | main = "./src/index.ts" 5 | 6 | [[services]] 7 | binding = "STORE" 8 | service = "db-service" 9 | entrypoint = "AdminStore" 10 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/public-service/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema disabled 2 | name = "public-service" 3 | compatibility_date = "2024-04-03" 4 | main = "./src/index.ts" 5 | 6 | [[services]] 7 | binding = "STORE" 8 | service = "db-service" 9 | entrypoint = "PublicStore" 10 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/public-service/src/utils/escape.ts: -------------------------------------------------------------------------------- 1 | const lookup = { 2 | "&": "&", 3 | '"': """, 4 | "'": "'", 5 | "<": "<", 6 | ">": ">", 7 | }; 8 | 9 | export const escape = (s: string) => 10 | s.replace(/[&"'<>]/g, (c) => lookup[c as keyof typeof lookup]); 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "mtxr.sqltools-driver-sqlite", 4 | "mtxr.sqltools", 5 | "editorconfig.editorconfig", 6 | "dbaeumer.vscode-eslint", 7 | "tamasfe.even-better-toml", 8 | "christian-kohler.npm-intellisense", 9 | "esbenp.prettier-vscode", 10 | "yoavbls.pretty-ts-errors" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /discord-bot/scripts/register.js: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { join } from "path"; 3 | import { fileURLToPath } from "url"; 4 | import { ALL_COMMANDS } from "./commands.js"; 5 | import { InstallGlobalCommands } from "./utils.js"; 6 | 7 | dotenv.config({ 8 | path: join(fileURLToPath(import.meta.url), "../../.dev.vars"), 9 | }); 10 | 11 | InstallGlobalCommands(process.env.APP_ID, ALL_COMMANDS); 12 | -------------------------------------------------------------------------------- /discord-bot/src/utils.ts: -------------------------------------------------------------------------------- 1 | // Simple method that returns a random emoji from list 2 | export function getRandomEmoji() { 3 | const emojiList = [ 4 | "😭", 5 | "😄", 6 | "😌", 7 | "🤓", 8 | "😎", 9 | "😤", 10 | "🤖", 11 | "😶‍🌫️", 12 | "🌏", 13 | "📸", 14 | "💿", 15 | "👋", 16 | "🌊", 17 | "✨", 18 | ]; 19 | return emojiList[Math.floor(Math.random() * emojiList.length)]; 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "sqltools.connections": [ 3 | { 4 | "name": "todo-app", 5 | "driver": "SQLite", 6 | "database": "./public-admin-api-interfaces/db-service/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/e102958f9f3d7c16a4efe6ecf2b54146843ba810f49017e5a43ecb2af0cee1e1.sqlite", 7 | "connectionTimeout": 15 8 | } 9 | ], 10 | "sqltools.useNodeRuntime": true, 11 | "typescript.tsdk": "./node_modules/typescript/lib" 12 | } 13 | -------------------------------------------------------------------------------- /discord-bot/wrangler.init.toml: -------------------------------------------------------------------------------- 1 | #:schema disabled 2 | # This file is needed temporarily to account for an API bug which prevents 3 | # you from one-shot creating a Worker service with a binding to itself. 4 | # We plan to fix this shortly. In the meantime, this file creates the service 5 | # to begin with without that binding, and then you can deploy it for real 6 | # with the binding, immediately afterwards. 7 | name = "discord-bot" 8 | compatibility_date = "2024-04-03" 9 | main = "./integrations-marketplace/discord.ts" 10 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/db-service/wrangler.init.toml: -------------------------------------------------------------------------------- 1 | #:schema disabled 2 | # This file is needed temporarily to account for an API bug which prevents 3 | # you from one-shot creating a Worker service with a binding to itself. 4 | # We plan to fix this shortly. In the meantime, this file creates the service 5 | # to begin with without that binding, and then you can deploy it for real 6 | # with the binding, immediately afterwards. 7 | name = "db-service" 8 | compatibility_date = "2024-04-03" 9 | main = "src/index.ts" 10 | -------------------------------------------------------------------------------- /discord-bot/scripts/commands.js: -------------------------------------------------------------------------------- 1 | export const TEST_COMMAND = { 2 | name: "hello", 3 | description: "Say hello", 4 | type: 1, 5 | }; 6 | 7 | export const ROLLADICE_COMMAND = { 8 | name: "rolladice", 9 | description: "Roll a dice", 10 | type: 1, 11 | options: [ 12 | { 13 | name: "sides", 14 | description: "Number of sides on the dice to roll", 15 | type: 4, 16 | required: true, 17 | min_value: 1, 18 | max_value: 24, 19 | }, 20 | ], 21 | }; 22 | 23 | export const ALL_COMMANDS = [TEST_COMMAND, ROLLADICE_COMMAND]; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "skipLibCheck": true, 5 | "target": "ESNext", 6 | "allowJs": true, 7 | "resolveJsonModule": true, 8 | "moduleDetection": "force", 9 | "isolatedModules": true, 10 | "strict": true, 11 | "noUncheckedIndexedAccess": true, 12 | "module": "Preserve", 13 | "moduleResolution": "Bundler", 14 | "noEmit": true, 15 | "lib": ["ESNext"], 16 | "types": ["@cloudflare/workers-types/2023-07-01"], 17 | "incremental": true, 18 | "newLine": "lf" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/db-service/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema disabled 2 | name = "db-service" 3 | compatibility_date = "2024-04-03" 4 | main = "src/index.ts" 5 | 6 | [[d1_databases]] 7 | binding = "D1" 8 | database_name = "todo-app" 9 | database_id = "ee5a15b7-c204-4621-a1ff-abd97d433514" # change this if you're deploying this yourself! 10 | 11 | [[services]] 12 | binding = "STORE" 13 | service = "db-service" 14 | entrypoint = "AdminStore" 15 | 16 | # to routinely clean up our public demo 17 | # feel free to remove this if you're deploying this yourself! 18 | [triggers] 19 | crons = ["*/5 * * * *"] 20 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | 2 | on: 3 | pull_request: {} 4 | workflow_dispatch: {} 5 | push: 6 | branches: 7 | - main 8 | - master 9 | schedule: 10 | - cron: '0 0 * * *' 11 | name: Semgrep config 12 | jobs: 13 | semgrep: 14 | name: semgrep/ci 15 | runs-on: ubuntu-20.04 16 | env: 17 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 18 | SEMGREP_URL: https://cloudflare.semgrep.dev 19 | SEMGREP_APP_URL: https://cloudflare.semgrep.dev 20 | SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version 21 | container: 22 | image: returntocorp/semgrep 23 | steps: 24 | - uses: actions/checkout@v3 25 | - run: semgrep ci 26 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/public-service/src/user.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | To Do App 7 | 8 | 9 |

Welcome !

10 |

Here are your tasks:

11 |
    12 |
  1. 13 |
    14 | 22 | 23 |
    24 |
  2. 25 |
26 |

27 | 28 | 29 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/public-service/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | To Do App 7 | 8 | 9 |

To Do App

10 |

11 | Register now and start adding tasks! Our users have already created 12 | tasks! 13 |

14 |
15 | 25 | 26 |

27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/db-service/migrations/0001_init.sql: -------------------------------------------------------------------------------- 1 | -- Migration number: 0001 2024-04-03T15:26:58.123Z 2 | CREATE TABLE IF NOT EXISTS users ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | username TEXT NOT NULL UNIQUE CHECK ( 5 | LENGTH(username) BETWEEN 1 AND 51 6 | ), 7 | created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 8 | ); 9 | CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); 10 | CREATE TABLE IF NOT EXISTS tasks ( 11 | id INTEGER PRIMARY KEY AUTOINCREMENT, 12 | title TEXT NOT NULL CHECK ( 13 | LENGTH(title) BETWEEN 1 AND 141 14 | ), 15 | completed INTEGER NOT NULL DEFAULT 0 CHECK (completed IN (0, 1)), 16 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, 17 | created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 18 | ); 19 | CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id); 20 | CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed) 21 | WHERE completed = 1; 22 | -------------------------------------------------------------------------------- /discord-bot/src/index.ts: -------------------------------------------------------------------------------- 1 | import { WorkerEntrypoint } from "cloudflare:workers"; 2 | import { 3 | Interaction, 4 | InteractionResponse, 5 | InteractionResponseType, 6 | } from "discord-interactions"; 7 | import type { ROLLADICE_COMMAND } from "../scripts/commands.js"; 8 | import { getRandomEmoji } from "./utils.js"; 9 | 10 | export class DiscordBot extends WorkerEntrypoint { 11 | async hello(): Promise { 12 | return { 13 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 14 | data: { 15 | content: "hello world " + getRandomEmoji(), 16 | }, 17 | }; 18 | } 19 | 20 | async rolladice( 21 | body: Interaction, 22 | ): Promise { 23 | return { 24 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 25 | data: { 26 | content: `You rolled a ${Math.ceil((body.data?.options?.[0]?.value as number) * Math.random())}`, 27 | }, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/admin-service/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Admin Service 7 | 8 | 9 |

[ADMIN] To Do App

10 |
11 |
Number of users
12 |
13 |
Number of tasks
14 |
15 |
Number of completed tasks
16 |
17 |
18 |
19 | 20 | 21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /discord-bot/src/types.d.ts: -------------------------------------------------------------------------------- 1 | export * from "discord-interactions"; 2 | 3 | // We're cheekily patching this module with some additional 4 | // types to paint a better picture of what a first-class Workers 5 | // integration could look like. 6 | declare module "discord-interactions" { 7 | export interface InteractionResponse { 8 | type: number; 9 | data: { 10 | tts?: boolean; 11 | content?: string; 12 | // others... 13 | }; 14 | } 15 | export interface InteractionData< 16 | Options extends { type: number; required?: boolean }[] = [], 17 | > { 18 | id: string; 19 | name: string; 20 | type: number; 21 | options?: Options[0]["type"] extends 4 22 | ? Options[0]["required"] extends true 23 | ? { 24 | name: string; 25 | type: number; 26 | value: number; 27 | } 28 | : { name: string; type: number; value?: number } 29 | : { name: string; type: number; value?: string | number | boolean }[]; 30 | // others... 31 | } 32 | 33 | export interface Interaction< 34 | Command extends { options: { type: number; required?: boolean }[] } = { 35 | options: []; 36 | }, 37 | > { 38 | id: string; 39 | application_id: string; 40 | type: InteractionType; 41 | data?: InteractionData; 42 | token: string; 43 | version: number; 44 | // others... 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /discord-bot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudflare/discord-bot", 3 | "private": true, 4 | "homepage": "https://discord-bot.gregbrimble.workers.dev/", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/cloudflare/js-rpc-and-entrypoints-demo.git", 8 | "directory": "./discord-bot" 9 | }, 10 | "type": "module", 11 | "scripts": { 12 | "predeploy": "npm run validate", 13 | "deploy": "npx wrangler deploy -c wrangler.init.toml && npx wrangler deploy -c wrangler.toml", 14 | "eslint": "npx eslint . --ignore-pattern **/scripts/** --ignore-path ../.gitignore --cache --cache-location ../.eslintcache", 15 | "format": "npm run prettier -- --check", 16 | "format:fix": "npm run prettier -- --write", 17 | "lint": "npm run eslint", 18 | "lint:fix": "npm run eslint -- --fix", 19 | "prettier": "npx prettier . --ignore-path ../.gitignore", 20 | "register": "node ./scripts/register.js", 21 | "test": "npx vitest", 22 | "typecheck": "npm run typescript -- --noEmit", 23 | "typescript": "npx tsc", 24 | "validate": "CI=true npx concurrently --group \"npm:lint\" \"npm:format\" \"npm:test\" \"npm:typecheck\"" 25 | }, 26 | "dependencies": { 27 | "discord-interactions": "3.4.0" 28 | }, 29 | "devDependencies": { 30 | "dotenv": "16.4.5", 31 | "vitest": "1.4.0" 32 | }, 33 | "engines": { 34 | "node": "20.12.0", 35 | "npm": "10.5.0" 36 | }, 37 | "volta": { 38 | "node": "20.12.0", 39 | "npm": "10.5.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /discord-bot/scripts/utils.js: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { join } from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | dotenv.config({ 6 | path: join(fileURLToPath(import.meta.url), "../../.dev.vars"), 7 | }); 8 | 9 | export async function DiscordRequest(endpoint, options) { 10 | // append endpoint to root API URL 11 | const url = "https://discord.com/api/v10/" + endpoint; 12 | // Stringify payloads 13 | if (options.body) options.body = JSON.stringify(options.body); 14 | // Use node-fetch to make requests 15 | const res = await fetch(url, { 16 | headers: { 17 | Authorization: `Bot ${process.env.DISCORD_TOKEN}`, 18 | "Content-Type": "application/json; charset=UTF-8", 19 | "User-Agent": 20 | "DiscordBot (https://github.com/discord/discord-example-app, 1.0.0)", 21 | }, 22 | ...options, 23 | }); 24 | // throw API errors 25 | if (!res.ok) { 26 | const data = await res.json(); 27 | console.log(res.status); 28 | throw new Error(JSON.stringify(data)); 29 | } 30 | // return original response 31 | return res; 32 | } 33 | 34 | export async function InstallGlobalCommands(appId, commands) { 35 | // API endpoint to overwrite global commands 36 | const endpoint = `applications/${appId}/commands`; 37 | 38 | try { 39 | // This is calling the bulk overwrite endpoint: https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands 40 | await DiscordRequest(endpoint, { method: "PUT", body: commands }); 41 | } catch (err) { 42 | console.error(err); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /discord-bot/integrations-marketplace/discord.ts: -------------------------------------------------------------------------------- 1 | import { WorkerEntrypoint } from "cloudflare:workers"; 2 | import { 3 | Interaction, 4 | InteractionResponse, 5 | InteractionResponseType, 6 | InteractionType, 7 | verifyKey, 8 | } from "discord-interactions"; 9 | import { DiscordBot } from "../src/index.js"; 10 | 11 | interface Env { 12 | DISCORD_TOKEN: string; 13 | APP_ID: string; 14 | PUBLIC_KEY: string; 15 | 16 | DISCORD_BOT: Service; 17 | } 18 | 19 | export default class extends WorkerEntrypoint { 20 | async fetch(request: Request) { 21 | const signature = request.headers.get("X-Signature-Ed25519") || ""; 22 | const timestamp = request.headers.get("X-Signature-Timestamp") || ""; 23 | 24 | const isValidRequest = verifyKey( 25 | await request.clone().arrayBuffer(), 26 | signature, 27 | timestamp, 28 | this.env.PUBLIC_KEY, 29 | ); 30 | if (!isValidRequest) { 31 | return new Response("Bad request signature", { status: 401 }); 32 | } 33 | 34 | const body = await request.json(); 35 | 36 | if (body.type === InteractionType.PING) { 37 | return new Response( 38 | JSON.stringify({ type: InteractionResponseType.PONG }), 39 | ); 40 | } 41 | 42 | if (body.type === InteractionType.APPLICATION_COMMAND) { 43 | const { name } = body.data as { 44 | name: string; 45 | id: string; 46 | type: number; 47 | }; 48 | 49 | const result = await ( 50 | this.env.DISCORD_BOT[name as keyof typeof this.env.DISCORD_BOT] as ( 51 | body: Interaction, 52 | ) => Promise 53 | )(body); 54 | console.log(JSON.stringify(result)); 55 | 56 | return new Response(JSON.stringify(result)); 57 | } 58 | 59 | return new Response(null, { status: 404 }); 60 | } 61 | } 62 | 63 | export { DiscordBot }; 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudflare/js-rpc-and-entrypoints-demo", 3 | "private": true, 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/cloudflare/js-rpc-and-entrypoints-demo.git" 7 | }, 8 | "type": "module", 9 | "workspaces": [ 10 | "./public-admin-api-interfaces", 11 | "./discord-bot" 12 | ], 13 | "scripts": { 14 | "eslint": "npx eslint . --ignore-path .gitignore --cache --cache-location ./.eslintcache", 15 | "format": "npm run prettier -- --check", 16 | "format:fix": "npm run prettier -- --write", 17 | "lint": "npm run eslint", 18 | "lint:fix": "npm run eslint -- --fix", 19 | "prettier": "npx prettier .", 20 | "validate": "CI=true npx concurrently --group -n lint,format,test,typecheck \"npm:lint\" \"npm:format\" \"npm run test --workspaces\" \"npm run typecheck --workspaces\"" 21 | }, 22 | "prettier": { 23 | "plugins": [ 24 | "prettier-plugin-organize-imports", 25 | "prettier-plugin-packagejson", 26 | "prettier-plugin-sort-json" 27 | ] 28 | }, 29 | "eslintConfig": { 30 | "parser": "@typescript-eslint/parser", 31 | "plugins": [ 32 | "@typescript-eslint", 33 | "eslint-plugin-isaacscript", 34 | "eslint-plugin-unicorn" 35 | ], 36 | "extends": [ 37 | "eslint:recommended", 38 | "plugin:@typescript-eslint/eslint-recommended", 39 | "plugin:@typescript-eslint/recommended" 40 | ], 41 | "rules": { 42 | "isaacscript/no-template-curly-in-string-fix": "error", 43 | "unicorn/expiring-todo-comments": "error" 44 | }, 45 | "root": true 46 | }, 47 | "devDependencies": { 48 | "@cloudflare/workers-types": "4.20240404.0", 49 | "@typescript-eslint/eslint-plugin": "7.5.0", 50 | "@typescript-eslint/parser": "7.5.0", 51 | "concurrently": "8.2.2", 52 | "eslint": "8.57.0", 53 | "eslint-plugin-isaacscript": "3.12.2", 54 | "eslint-plugin-unicorn": "51.0.1", 55 | "prettier": "3.2.5", 56 | "prettier-plugin-organize-imports": "3.2.4", 57 | "prettier-plugin-packagejson": "2.4.14", 58 | "prettier-plugin-sort-json": "4.0.0", 59 | "typescript": "5.4.3", 60 | "wrangler": "using-keyword-experimental" 61 | }, 62 | "engines": { 63 | "node": "20.12.0", 64 | "npm": "10.5.0" 65 | }, 66 | "volta": { 67 | "node": "20.12.0", 68 | "npm": "10.5.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JavaScript-native RPC on Cloudflare Workers <> Named Entrypoints — Demos 2 | 3 | This is a collection of examples of communicating between multiple Cloudflare Workers using the [remote-procedure call (RPC) system](https://developers.cloudflare.com/workers/runtime-apis/rpc) that is built into the Workers runtime. 4 | 5 | Specifically, these examples highight patterns for how to expose multiple [named entrypoints](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/#named-entrypoints) from a single Cloudflare Worker, and bind directly to them using [Service bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/). 6 | 7 | Learn more about the RPC system in Cloudflare Workers by reading the [blog post](https://blog.cloudflare.com/javascript-native-rpc). 8 | 9 | ## Demos 10 | 11 | 1. [Public & Admin API Interfaces](./public-admin-api-interfaces/README.md) 12 | 13 | This is a comprehensive "to do" application example. It is composed of three different services, a D1 database, two web applications (one protected by Cloudflare Access), and two different Worker Entrypoints for different user role permission levels. 14 | 15 | ![Diagram of the public-admin-api-interfaces system](./public-admin-api-interfaces-overview.png) 16 | 17 | 1. [Discord Bot](./discord-bot/README.md) 18 | 19 | ![Diagram of the discord-bot system](./discord-bot-overview.png) 20 | 21 | This is a Discord bot, written as a Worker Entrypoint. It sketches a possible future where application integrations could be written as Worker Entrypoints, without needing to worry about boilerplate like routing or authentication (and instead, just with simple JavaScript hook-like functionality). 22 | 23 | ## Getting started 24 | 25 | 1. Clone this repository: `git clone https://github.com/cloudflare/js-rpc-and-entrypoints-demo.git && cd js-rpc-and-entrypoints-demo` 26 | 1. `npm install` in the root of this monorepo. 27 | 1. `cd` into one of the applications, and open its `README.md` to learn more. 28 | 29 | ## Documentation 30 | 31 | - [RPC](https://developers.cloudflare.com/workers/runtime-apis/rpc) 32 | - [Named entrypoints](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/#named-entrypoints) 33 | - [Service bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/) 34 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudflare/public-admin-api-interfaces", 3 | "private": true, 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/cloudflare/js-rpc-and-entrypoints-demo.git", 7 | "directory": "./public-admin-api-interfaces" 8 | }, 9 | "type": "module", 10 | "scripts": { 11 | "predeploy": "npm run validate", 12 | "deploy": "npm run migrations:apply:remote && npm run deploy:db-service && npm run deploy:public-service && npm run deploy:admin-service", 13 | "deploy:admin-service": "npx wrangler deploy -c admin-service/wrangler.toml", 14 | "predeploy:db-service": "npx wrangler deploy -c db-service/wrangler.init.toml", 15 | "deploy:db-service": "npx wrangler deploy -c db-service/wrangler.toml", 16 | "deploy:public-service": "npx wrangler deploy -c public-service/wrangler.toml", 17 | "predev": "npm run migrations:apply:local", 18 | "dev": "npx concurrently -n db-service,public-service,admin-service -k \"npm run dev:db-service\" \"sleep 2 && npm run dev:public-service\" \"sleep 4 && npm run dev:admin-service\"", 19 | "dev:admin-service": "sleep 4 && npx wrangler dev -c admin-service/wrangler.toml --port 9001", 20 | "dev:db-service": "npx wrangler dev -c db-service/wrangler.toml --port 8787 --test-scheduled", 21 | "dev:public-service": "sleep 2 && npx wrangler dev -c public-service/wrangler.toml --port 9000", 22 | "eslint": "npx eslint . --ignore-path ../.gitignore --cache --cache-location ../.eslintcache", 23 | "format": "npm run prettier -- --check", 24 | "format:fix": "npm run prettier -- --write", 25 | "lint": "npm run eslint", 26 | "lint:fix": "npm run eslint -- --fix", 27 | "migrations:apply:local": "CI=true npx wrangler d1 migrations apply todo-app --local -c db-service/wrangler.toml", 28 | "migrations:apply:remote": "CI=true npx wrangler d1 migrations apply todo-app --remote -c db-service/wrangler.toml", 29 | "prettier": "npx prettier . --ignore-path ../.gitignore", 30 | "test": "npx vitest", 31 | "typecheck": "npm run typescript -- --noEmit", 32 | "typescript": "npx tsc", 33 | "validate": "CI=true npx concurrently --group \"npm:lint\" \"npm:format\" \"npm:test\" \"npm:typecheck\"" 34 | }, 35 | "devDependencies": { 36 | "vitest": "1.4.0" 37 | }, 38 | "engines": { 39 | "node": "20.12.0", 40 | "npm": "10.5.0" 41 | }, 42 | "volta": { 43 | "node": "20.12.0", 44 | "npm": "10.5.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/admin-service/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { AdminStore } from "../../db-service/src/index.js"; 2 | import homePage from "./index.html"; 3 | 4 | interface Env { 5 | STORE: Service; 6 | } 7 | 8 | export default { 9 | async fetch(request, env) { 10 | // In a production app, you'd want to protect this with Cloudflare Access 11 | // and validate any incoming requests by checking their `CF_Authorization` header 12 | // https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/ 13 | 14 | const method = request.method.toUpperCase(); 15 | const { pathname } = new URL(request.url); 16 | 17 | const renderHomePage = async (error?: string) => { 18 | const users = 19 | (await env.STORE.countAllUsers()) ?? "Could not count users"; 20 | const tasks = 21 | (await env.STORE.countAllTasks()) ?? "Could not count tasks"; 22 | const completedTasks = 23 | (await env.STORE.countAllCompletedTasks()) ?? 24 | "Could not count completed tasks"; 25 | 26 | return new HTMLRewriter() 27 | .on("#users", { 28 | async element(element) { 29 | element.setInnerContent(users.toString()); 30 | }, 31 | }) 32 | .on("#tasks", { 33 | async element(element) { 34 | element.setInnerContent(tasks.toString()); 35 | }, 36 | }) 37 | .on("#completed-tasks", { 38 | async element(element) { 39 | element.setInnerContent(completedTasks.toString()); 40 | }, 41 | }) 42 | .on("#error", { 43 | element(element) { 44 | if (error) { 45 | element.setInnerContent(error); 46 | } else { 47 | element.remove(); 48 | } 49 | }, 50 | }) 51 | .transform( 52 | new Response(homePage, { headers: { "Content-Type": "text/html" } }), 53 | ); 54 | }; 55 | 56 | if (method === "GET" && pathname === "/") { 57 | return renderHomePage(); 58 | } 59 | 60 | if (method === "POST" && pathname === "/") { 61 | const formData = await request.formData(); 62 | const action = formData.get("action"); 63 | 64 | if (typeof action !== "string") { 65 | return renderHomePage("Invalid action."); 66 | } 67 | 68 | switch (action) { 69 | case "delete-users": 70 | await env.STORE.deleteAllUsers(); 71 | break; 72 | case "delete-tasks": 73 | await env.STORE.deleteAllTasks(); 74 | break; 75 | case "delete-completed-tasks": 76 | await env.STORE.deleteAllCompletedTasks(); 77 | break; 78 | default: 79 | return renderHomePage("Invalid action."); 80 | } 81 | 82 | return renderHomePage(); 83 | } 84 | 85 | return new Response(null, { status: 404 }); 86 | }, 87 | } as ExportedHandler; 88 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/public-service/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { PublicStore } from "../../db-service/src/index.js"; 2 | import homePage from "./index.html"; 3 | import { handleUserRequest } from "./user.js"; 4 | 5 | const usernameRegExp = "[a-zA-Z0-9_-]{1,50}"; 6 | 7 | interface Env { 8 | STORE: Service; 9 | } 10 | 11 | export default { 12 | async fetch(request, env) { 13 | const url = new URL(request.url); 14 | const method = request.method.toUpperCase(); 15 | 16 | const renderHomepage = async (error?: string) => { 17 | const tasks = await env.STORE.countAllTasks(); 18 | 19 | return new HTMLRewriter() 20 | .on("#tasks", { 21 | element(element) { 22 | element.setInnerContent(tasks.toString()); 23 | }, 24 | }) 25 | .on("#error", { 26 | element(element) { 27 | if (error) { 28 | element.setInnerContent(error); 29 | } else { 30 | element.remove(); 31 | } 32 | }, 33 | }) 34 | .transform( 35 | new Response(homePage, { headers: { "Content-Type": "text/html" } }), 36 | ); 37 | }; 38 | 39 | if (method === "GET" && url.pathname === "/") { 40 | return await renderHomepage(); 41 | } 42 | 43 | if (method === "POST" && url.pathname === "/") { 44 | const formData = await request.formData(); 45 | const username = formData.get("username"); 46 | 47 | // For brevity, we don't have any real authentication in this example application. 48 | // In a production app, you would want to first confirm the user's identity and then 49 | // set a session cookie before redirecting them to their page. 50 | if ( 51 | typeof username !== "string" || 52 | !username.match(new RegExp(`^${usernameRegExp}$`)) 53 | ) { 54 | return await renderHomepage( 55 | "Invalid username. Username should be between 1–50 characters, and be composed of only alphanumeric characters, dashes and underscores.", 56 | ); 57 | } 58 | 59 | await env.STORE.upsertUser(username); 60 | 61 | return new Response(null, { 62 | status: 302, 63 | headers: { Location: `/user/${username}/` }, 64 | }); 65 | } 66 | 67 | const match = url.pathname.match( 68 | new RegExp( 69 | `^/user/(?${usernameRegExp})(?/(?:new|complete|uncomplete)?)$`, 70 | ), 71 | ); 72 | if (match) { 73 | const { action, username } = match.groups || {}; 74 | // Again, in production you'd want to actually validate a session cookie here 75 | // to ensure the request is coming from the user who owns this page. 76 | if (!username || !action) { 77 | return new Response(null, { status: 404 }); 78 | } 79 | 80 | return handleUserRequest({ 81 | request, 82 | method, 83 | username, 84 | action, 85 | STORE: env.STORE, 86 | }); 87 | } 88 | 89 | return new Response(null, { status: 404 }); 90 | }, 91 | } as ExportedHandler; 92 | -------------------------------------------------------------------------------- /discord-bot/README.md: -------------------------------------------------------------------------------- 1 | # Discord Bot demo 2 | 3 | This is a Discord bot, written as a Worker Entrypoint. It sketches a possible future where application integrations could be written as Worker Entrypoints, without needing to worry about boilerplate like routing or authentication (and instead, just with simple JavaScript hook-like functionality). 4 | 5 | ## Getting started 6 | 7 | 1. Follow the instructions in the [README.md in the root of this monorepo](../README.md). 8 | 1. [Create a Discord app](https://discord.com/developers/docs/quick-start/getting-started), authenticate with it for your server, and copy the `DISCORD_TOKEN`, `APP_ID`, and `PUBLIC_KEY` into [`.dev.vars`](./.dev.vars) (see the [`.dev.vars.example`](./.dev.vars.example) file for an example). 9 | 1. Register the app's commands by running `npm run register`. 10 | 1. Deploy the bot to Cloudflare with `npm run deploy`. 11 | 1. Copy the deployment URL (`https://discord-bot..workers.dev/`) into the "Interactions endpoint URL" field in the Discord app's settings, and click "Save changes". This will validate the app is running correctly. 12 | 13 | ## System Design 14 | 15 | - `discord-bot` is a Worker service which demonstrates a future where there is no longer any need for boilerplate such as routing or authentication when creating an application integration such as a Discord bot. 16 | 17 | ![Diagram of the discord-bot system](./discord-bot.png) 18 | 19 | 1. A request from Discord hits Cloudflare's hypothetical new "App Marketplace". Discord and Cloudflare maintain this relationship. Cloudflare parses the request and validates its authenticity before invoking the user's Discord App" service over RPC. 20 | 2. The Discord app integration passes along the Discord interaction payload directly to the appropriate command function in the user-defined Worker Entrypoint. The user only needs to implement these command functions. They return an interaction response, which the app integration then serializes and sends back to Discord in reply. 21 | 22 | --- 23 | 24 | In reality, since this app marketplace doesn't actually exist today, we've bound to this entrypoint from it's own service which defines a typical `fetch` handler and does implement this validation/parsing work for us. 25 | 26 | --- 27 | 28 | ## Attribution 29 | 30 | This demo is adapted from [the official Discord example app](https://github.com/discord/discord-example-app). 31 | 32 | ``` 33 | MIT License 34 | 35 | Copyright (c) 2022 Shay DeWael 36 | 37 | Permission is hereby granted, free of charge, to any person obtaining a copy 38 | of this software and associated documentation files (the "Software"), to deal 39 | in the Software without restriction, including without limitation the rights 40 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 41 | copies of the Software, and to permit persons to whom the Software is 42 | furnished to do so, subject to the following conditions: 43 | 44 | The above copyright notice and this permission notice shall be included in all 45 | copies or substantial portions of the Software. 46 | 47 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 48 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 49 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 50 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 51 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 52 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 53 | SOFTWARE. 54 | ``` 55 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/db-service/src/index.ts: -------------------------------------------------------------------------------- 1 | import { RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; 2 | 3 | interface Env { 4 | D1: D1Database; 5 | STORE: Service; 6 | } 7 | 8 | export class AuthedStore extends RpcTarget { 9 | #userId: number; 10 | #D1: D1Database; 11 | 12 | constructor(userId: number, d1: D1Database) { 13 | super(); 14 | this.#userId = userId; 15 | this.#D1 = d1; 16 | } 17 | 18 | async getTasks() { 19 | return ( 20 | await this.#D1 21 | .prepare( 22 | "SELECT id, title, completed FROM tasks WHERE user_id = ? ORDER BY created_at ASC;", 23 | ) 24 | .bind(this.#userId) 25 | .all<{ id: number; title: string; completed: number }>() 26 | ).results.map((task) => ({ 27 | id: task.id, 28 | title: task.title, 29 | completed: task.completed === 1, 30 | })); 31 | } 32 | 33 | async createTask(title: string) { 34 | await this.#D1 35 | .prepare("INSERT INTO tasks (title, user_id) VALUES (?, ?);") 36 | .bind(title, this.#userId) 37 | .run(); 38 | } 39 | 40 | async completeTask(id: number) { 41 | await this.#D1 42 | .prepare("UPDATE tasks SET completed = 1 WHERE id = ?;") 43 | .bind(id) 44 | .run(); 45 | } 46 | 47 | async uncompleteTask(id: number) { 48 | await this.#D1 49 | .prepare("UPDATE tasks SET completed = 0 WHERE id = ?;") 50 | .bind(id) 51 | .run(); 52 | } 53 | } 54 | 55 | export class PublicStore extends WorkerEntrypoint { 56 | async countAllTasks() { 57 | const tasks = await this.env.D1.prepare( 58 | "SELECT COUNT(0) AS count FROM tasks;", 59 | ).first("count"); 60 | 61 | if (tasks === null) { 62 | throw new Error("Failed to count tasks"); 63 | } 64 | 65 | return tasks; 66 | } 67 | 68 | async upsertUser(username: string) { 69 | await this.env.D1.prepare( 70 | "INSERT OR IGNORE INTO users (username) VALUES (?);", 71 | ) 72 | .bind(username) 73 | .run(); 74 | } 75 | 76 | async getStoreForUserByUsername(username: string) { 77 | const id = await this.env.D1.prepare( 78 | "SELECT id FROM users WHERE username = ?;", 79 | ) 80 | .bind(username) 81 | .first("id"); 82 | if (id) { 83 | return new AuthedStore(id, this.env.D1); 84 | } 85 | 86 | throw new Error("User not found"); 87 | } 88 | } 89 | 90 | export class AdminStore extends WorkerEntrypoint { 91 | async healthcheck() { 92 | const result = 93 | await this.env.D1.prepare("SELECT 1 AS alive;").first("alive"); 94 | return result === 1; 95 | } 96 | 97 | async countAllUsers() { 98 | return await this.env.D1.prepare( 99 | "SELECT COUNT(0) AS count FROM users;", 100 | ).first("count"); 101 | } 102 | 103 | async countAllTasks() { 104 | return await this.env.D1.prepare( 105 | "SELECT COUNT(0) AS count FROM tasks;", 106 | ).first("count"); 107 | } 108 | 109 | async countAllCompletedTasks() { 110 | return await this.env.D1.prepare( 111 | "SELECT COUNT(0) AS count FROM tasks WHERE completed = 1;", 112 | ).first("count"); 113 | } 114 | 115 | async deleteAllUsers() { 116 | return await this.env.D1.exec("DELETE FROM users;"); 117 | } 118 | 119 | async deleteAllTasks() { 120 | return await this.env.D1.exec("DELETE FROM tasks;"); 121 | } 122 | 123 | async deleteAllCompletedTasks() { 124 | return await this.env.D1.exec("DELETE FROM tasks WHERE completed = 1;"); 125 | } 126 | } 127 | 128 | export default class extends WorkerEntrypoint { 129 | async fetch() { 130 | const ok = await this.env.STORE.healthcheck(); 131 | return new Response(ok ? "OK" : "NOT OK", { status: ok ? 200 : 500 }); 132 | } 133 | // to routinely clean up our public demo 134 | // feel free to remove this if you're deploying this yourself! 135 | async scheduled() { 136 | this.ctx.waitUntil(this.env.STORE.deleteAllUsers()); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/public-service/src/user.ts: -------------------------------------------------------------------------------- 1 | import { RpcStub } from "cloudflare:workers"; 2 | import type { AuthedStore, PublicStore } from "../../db-service/src"; 3 | import userPage from "./user.html"; 4 | import { escape } from "./utils/escape.js"; 5 | import { html } from "./utils/html"; 6 | 7 | export const handleUserRequest = async ({ 8 | request, 9 | method, 10 | username, 11 | action, 12 | STORE, 13 | }: { 14 | request: Request; 15 | method: string; 16 | username: string; 17 | action: string; 18 | STORE: Service; 19 | }) => { 20 | let store: RpcStub; 21 | try { 22 | store = await STORE.getStoreForUserByUsername(username); 23 | } catch (thrown) { 24 | if (thrown instanceof Error && thrown.message === "User not found") { 25 | return new Response(null, { status: 404 }); 26 | } else { 27 | return new Response(null, { status: 500 }); 28 | } 29 | } 30 | 31 | const renderUserPage = async (error?: string) => { 32 | const tasks = await store.getTasks(); 33 | 34 | const renderTask = (task: (typeof tasks)[number]) => { 35 | return html`
  • 36 |
    40 | 49 | ${escape(task.title)} 50 |
    51 |
  • `; 52 | }; 53 | 54 | return new HTMLRewriter() 55 | .on("#username", { 56 | element(element) { 57 | element.setInnerContent(username); 58 | }, 59 | }) 60 | .on("#tasks", { 61 | element(element) { 62 | element.prepend(tasks.map((task) => renderTask(task)).join("\n"), { 63 | html: true, 64 | }); 65 | }, 66 | }) 67 | .on("#error", { 68 | element(element) { 69 | if (error) { 70 | element.setInnerContent(error); 71 | } else { 72 | element.remove(); 73 | } 74 | }, 75 | }) 76 | .transform( 77 | new Response(userPage, { 78 | headers: { "Content-Type": "text/html" }, 79 | }), 80 | ); 81 | }; 82 | 83 | if (method === "GET" && action === "/") { 84 | return await renderUserPage(); 85 | } 86 | 87 | if (method === "POST" && action === "/new") { 88 | const formData = await request.formData(); 89 | const title = formData.get("title"); 90 | 91 | if (typeof title !== "string" || !title.match(new RegExp(/^.{1,140}$/))) { 92 | return await renderUserPage( 93 | "Invalid task title. Title should be between 1–140 characters.", 94 | ); 95 | } 96 | 97 | await store.createTask(title); 98 | 99 | return new Response(null, { 100 | status: 302, 101 | headers: { Location: `/user/${username}/` }, 102 | }); 103 | } 104 | 105 | if ( 106 | method === "POST" && 107 | (action === "/complete" || action === "/uncomplete") 108 | ) { 109 | const formData = await request.formData(); 110 | const idString = formData.get("id"); 111 | 112 | if (typeof idString !== "string") { 113 | return await renderUserPage( 114 | "Invalid task toggle request. Must send a numeric 'id' string value.", 115 | ); 116 | } 117 | 118 | const id = parseInt(idString); 119 | 120 | if (isNaN(id)) { 121 | return await renderUserPage( 122 | "Invalid task toggle request. Must send a numeric 'id' string value.", 123 | ); 124 | } 125 | 126 | switch (action) { 127 | case "/complete": 128 | await store.completeTask(id); 129 | break; 130 | case "/uncomplete": 131 | await store.uncompleteTask(id); 132 | break; 133 | } 134 | 135 | return new Response(null, { 136 | status: 302, 137 | headers: { Location: `/user/${username}/` }, 138 | }); 139 | } 140 | 141 | return new Response(null, { status: 404 }); 142 | }; 143 | -------------------------------------------------------------------------------- /public-admin-api-interfaces/README.md: -------------------------------------------------------------------------------- 1 | # Public & Admin API Interfaces demo 2 | 3 | This is a comprehensive "to do" application example. It is composed of three different services, a D1 database, two web applications (one protected by Cloudflare Access), and two different Worker Entrypoints for different user role permission levels. 4 | 5 | ## Live demo 6 | 7 | [https://public-service.gregbrimble.workers.dev/](https://public-service.gregbrimble.workers.dev/) 8 | 9 | ## Getting started 10 | 11 | 1. Follow the instructions in the [README.md in the root of this monorepo](../README.md). 12 | 1. `npm run dev`. This will take a few seconds to spin up all three services. 13 | 1. Open the [`public-service` (http://localhost:9000/)](http://localhost:9000/) and [`admin-service` (http://localhost:9001/)](http://localhost:9001/) applications in your browser. 14 | 15 | To deploy: 16 | 17 | 1. Create a D1 database `npx wrangler d1 create todo-app` and open the `./db-service/wrangler.toml` file and change the `DB` binding's `database_id` to your own. 18 | 1. `npm run deploy`. 19 | 20 | ## System Design 21 | 22 | - [`db-service`](./db-service/) is a Worker which has access to a D1 database, and exposes two different entrypoints: `PublicStore` and `AdminStore`. These stores expose different functions, appropriate for their associated access level. For example, `PublicStore` exposes an innocuous `countAllTasks()` function, and `AdminStore` exposes a mutating `deleteAllTasks()` function. The D1 database can only be queried with code owned by the `db-service` Worker, since the binding is only present in the `db-service` Worker's [`wrangler.toml` file](./db-service/wrangler.toml). 23 | 24 | - [`public-service`](./public-service/) is a Worker which hosts our SaaS application dashboard. `public-service` has two webpages, `/` and `/user//`. The `/` page has a simplified login form, and the `/user//` page shows the user's tasks and allows the user to toggle the status of those tasks and add new ones. `public-service` has a Service binding to `db-service`'s `PublicStore` entrypoint. This allows the `public-service` to requesting an `AuthedStore` (a class extending `RpcTarget`) from the `PublicStore` for the given user. This `AuthedStore` has context on who this user is, and can only access that particular user's information. 25 | 26 | - [`admin-service`](./admin-service/) is a Worker (hypothetically protected by Cloudflare Access). It has a binding to `db-service`'s `AdminStore` entrypoint, which grants it access to administrative functions like `deleteAllTasks()`, `deleteAllUsers()`, and `deleteAllCompletedTasks()`. 27 | 28 | ![Diagram of the public-admin-api-interfaces system](./public-admin-api-interfaces.png) 29 | 30 | 1. A request from the open internet to the `public-service` is made. 31 | 2. The `public-service` makes an RPC request to `db-service`'s `PublicStore` entrypoint. The binding is configured in [`public-service`'s `wrangler.toml` file](./public-service/wrangler.toml). 32 | 3. `db-service`'s `PublicStore` can interface directly with the D1 database (or return an `AuthedStore` to `public-service` which can). 33 | 4. A request from the open internet to the `admin-service` is made, but is intercepted by Cloudflare Access. 34 | 5. If Cloudflare Access allows the request through, it hits the `admin-service` which validates the Cloudflare Access JWT. 35 | 6. The `admin-service` makes an RPC request to `db-service`'s `AdminStore` entrypoint. The binding is configured in [`admin-service`'s `wrangler.toml` file](./admin-service/wrangler.toml). 36 | 7. `db-service`'s `AdminStore` can interface directly with the D1 database. 37 | 38 | Not only does this design showcase how to design a secure system using entrypoints as a mechanism to gate role-based access control within your application, but it also shows how you might separate out ownership of a complex application across various development teams. For example, `public-service` might be developed by a team of frontend-focused developers, whereas `db-service` and the queries within might be maintained by database experts. 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/macos,node,git 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,node,git 3 | 4 | ### Git ### 5 | # Created by git for backups. To disable backups in Git: 6 | # $ git config --global mergetool.keepBackup false 7 | *.orig 8 | 9 | # Created by git when using merge tools for conflicts 10 | *.BACKUP.* 11 | *.BASE.* 12 | *.LOCAL.* 13 | *.REMOTE.* 14 | *_BACKUP_*.txt 15 | *_BASE_*.txt 16 | *_LOCAL_*.txt 17 | *_REMOTE_*.txt 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### macOS Patch ### 49 | # iCloud generated files 50 | *.icloud 51 | 52 | ### Node ### 53 | # Logs 54 | logs 55 | *.log 56 | npm-debug.log* 57 | yarn-debug.log* 58 | yarn-error.log* 59 | lerna-debug.log* 60 | .pnpm-debug.log* 61 | 62 | # Diagnostic reports (https://nodejs.org/api/report.html) 63 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 64 | 65 | # Runtime data 66 | pids 67 | *.pid 68 | *.seed 69 | *.pid.lock 70 | 71 | # Directory for instrumented libs generated by jscoverage/JSCover 72 | lib-cov 73 | 74 | # Coverage directory used by tools like istanbul 75 | coverage 76 | *.lcov 77 | 78 | # nyc test coverage 79 | .nyc_output 80 | 81 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 82 | .grunt 83 | 84 | # Bower dependency directory (https://bower.io/) 85 | bower_components 86 | 87 | # node-waf configuration 88 | .lock-wscript 89 | 90 | # Compiled binary addons (https://nodejs.org/api/addons.html) 91 | build/Release 92 | 93 | # Dependency directories 94 | node_modules/ 95 | jspm_packages/ 96 | 97 | # Snowpack dependency directory (https://snowpack.dev/) 98 | web_modules/ 99 | 100 | # TypeScript cache 101 | *.tsbuildinfo 102 | 103 | # Optional npm cache directory 104 | .npm 105 | 106 | # Optional eslint cache 107 | .eslintcache 108 | 109 | # Optional stylelint cache 110 | .stylelintcache 111 | 112 | # Microbundle cache 113 | .rpt2_cache/ 114 | .rts2_cache_cjs/ 115 | .rts2_cache_es/ 116 | .rts2_cache_umd/ 117 | 118 | # Optional REPL history 119 | .node_repl_history 120 | 121 | # Output of 'npm pack' 122 | *.tgz 123 | 124 | # Yarn Integrity file 125 | .yarn-integrity 126 | 127 | # dotenv environment variable files 128 | .env 129 | .env.development.local 130 | .env.test.local 131 | .env.production.local 132 | .env.local 133 | 134 | # parcel-bundler cache (https://parceljs.org/) 135 | .cache 136 | .parcel-cache 137 | 138 | # Next.js build output 139 | .next 140 | out 141 | 142 | # Nuxt.js build / generate output 143 | .nuxt 144 | dist 145 | 146 | # Gatsby files 147 | .cache/ 148 | # Comment in the public line in if your project uses Gatsby and not Next.js 149 | # https://nextjs.org/blog/next-9-1#public-directory-support 150 | # public 151 | 152 | # vuepress build output 153 | .vuepress/dist 154 | 155 | # vuepress v2.x temp and cache directory 156 | .temp 157 | 158 | # Docusaurus cache and generated files 159 | .docusaurus 160 | 161 | # Serverless directories 162 | .serverless/ 163 | 164 | # FuseBox cache 165 | .fusebox/ 166 | 167 | # DynamoDB Local files 168 | .dynamodb/ 169 | 170 | # TernJS port file 171 | .tern-port 172 | 173 | # Stores VSCode versions used for testing VSCode extensions 174 | .vscode-test 175 | 176 | # yarn v2 177 | .yarn/cache 178 | .yarn/unplugged 179 | .yarn/build-state.yml 180 | .yarn/install-state.gz 181 | .pnp.* 182 | 183 | ### Node Patch ### 184 | # Serverless Webpack directories 185 | .webpack/ 186 | 187 | # Optional stylelint cache 188 | 189 | # SvelteKit build / generate output 190 | .svelte-kit 191 | 192 | # End of https://www.toptal.com/developers/gitignore/api/macos,node,git 193 | 194 | ### Wrangler ### 195 | 196 | .wrangler/ 197 | .dev.vars 198 | --------------------------------------------------------------------------------