From a8b956b147e54b15bbeb17786b8b4c683941bbc3 Mon Sep 17 00:00:00 2001 From: Ethan Davidson <31261035+EthanThatOneKid@users.noreply.github.com> Date: Sun, 3 Sep 2023 17:46:57 -0700 Subject: [PATCH] Simple cron program (#15) * Create main.ts * wip `cf:deploy` task * add `cloudflare/workers/daily.ts` * bump version * add `cf:push`, `cf:serve` task Resolves . * rename `/cf` * wip * fix denoflare ### Inspiration [Discord conversation](https://discord.com/channels/684898665143206084/1147601703131152494/1147905102582132869): > [7:45 AM]John Spurlock: just define a scheduled handler alongside your module fetch handler like any other worker. Full signature looks like: > ```ts > scheduled(event: ModuleWorkerScheduledEvent, env: MyWorkerEnv, ctx: ModuleWorkerContext): Promise; > ``` > [Image](https://cdn.discordapp.com/attachments/1147601703131152494/1147905102334664704/image.png) > [7:47 AM]John Spurlock: there's no way to simulate cron events locally - for my workers I just call into common code that's also reachable by an admin endpoint from fetch for easy testing ### Results So what I ended up doing was following John Spurlock's advice in just defining `scheduled` as an adjacent method to the required `fetch` method. * Update .env.example * aesthetic changes --- .gitignore | 1 + cf/dailies/dailies.ts | 69 ++++++++++++++++++++++++++++++++++++++ cf/dailies/env.ts | 42 +++++++++++++++++++++++ cf/dailies/push/main.ts | 5 +++ cf/dailies/serve/main.ts | 5 +++ deno.jsonc | 4 ++- lib/denoflare/denoflare.ts | 68 +++++++++++++++++++++++++++++++++++++ lib/denoflare/mod.ts | 1 + 8 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 cf/dailies/dailies.ts create mode 100644 cf/dailies/env.ts create mode 100644 cf/dailies/push/main.ts create mode 100644 cf/dailies/serve/main.ts create mode 100644 lib/denoflare/denoflare.ts create mode 100644 lib/denoflare/mod.ts diff --git a/.gitignore b/.gitignore index 7a49ea1..2c1b756 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env ngrok.exe +.denoflare diff --git a/cf/dailies/dailies.ts b/cf/dailies/dailies.ts new file mode 100644 index 0000000..31a22b7 --- /dev/null +++ b/cf/dailies/dailies.ts @@ -0,0 +1,69 @@ +/** + * ScheduledEvent is the expected Cloudflare event for this worker. + */ +export interface ScheduledEvent { + cron: string; +} + +/** + * Env is the expected environment variables for this worker. + */ +export interface Env { + WEBHOOK_URL: string; +} + +/** + * Ctx is the expected context for this worker. + */ +interface Ctx { + waitUntil(promise: Promise): void; +} + +export default { + /** + * fetch is executed on every request. + */ + async fetch(request: Request, env: Env) { + if (request.method !== "GET") { + return new Response("Method not allowed", { status: 405 }); + } + + const url = new URL(request.url); + if (url.pathname !== "/__scheduled") { + return new Response("Not found", { status: 404 }); + } + + const cron = url.searchParams.get("cron"); + if (cron !== CRON_EXPRESSION) { + return new Response("Unexpected cron expression", { status: 400 }); + } + + return await execute(env.WEBHOOK_URL); + }, + + /** + * scheduled is executed daily at 12:00 AM UTC. + * + * See: + * - + */ + scheduled(event: ScheduledEvent, env: Env, ctx: Ctx) { + if (event.cron !== CRON_EXPRESSION) { + return; + } + + ctx.waitUntil(execute(env.WEBHOOK_URL)); + }, +}; + +function execute(webhookURL: string) { + return fetch(webhookURL, { method: "POST" }); +} + +/** + * CRON_EXPRESSION is the cron expression for the scheduled event. + * + * See: + * - + */ +const CRON_EXPRESSION = "0 0 * * *"; diff --git a/cf/dailies/env.ts b/cf/dailies/env.ts new file mode 100644 index 0000000..58a3536 --- /dev/null +++ b/cf/dailies/env.ts @@ -0,0 +1,42 @@ +import { load } from "lc-dailies/deps.ts"; +import { + denoflare, + DENOFLARE_VERSION_TAG, +} from "lc-dailies/lib/denoflare/mod.ts"; + +await load({ export: true }); + +const CF_ACCOUNT_ID = Deno.env.get("CF_ACCOUNT_ID")!; +const CF_API_TOKEN = Deno.env.get("CF_API_TOKEN")!; +const WEBHOOK_URL = Deno.env.get("WEBHOOK_URL")!; + +const DENOFLARE_SCRIPT_NAME = "lc-dailies"; +const DENOFLARE_SCRIPT_SPECIFIER = "cf/dailies/dailies.ts"; + +async function daily(...args: string[]) { + return await denoflare({ + versionTag: DENOFLARE_VERSION_TAG, + scriptName: DENOFLARE_SCRIPT_NAME, + path: DENOFLARE_SCRIPT_SPECIFIER, + cfAccountID: CF_ACCOUNT_ID, + cfAPIToken: CF_API_TOKEN, + localPort: 8080, + args, + }); +} + +export async function serve() { + return await daily( + "serve", + DENOFLARE_SCRIPT_NAME, + "--secret-binding", + `WEBHOOK_URL:${WEBHOOK_URL}`, + ); +} + +export async function push() { + return await daily( + "push", + DENOFLARE_SCRIPT_NAME, + ); +} diff --git a/cf/dailies/push/main.ts b/cf/dailies/push/main.ts new file mode 100644 index 0000000..93dc4fd --- /dev/null +++ b/cf/dailies/push/main.ts @@ -0,0 +1,5 @@ +import { push } from "../env.ts"; + +if (import.meta.main) { + await push(); +} diff --git a/cf/dailies/serve/main.ts b/cf/dailies/serve/main.ts new file mode 100644 index 0000000..49f6627 --- /dev/null +++ b/cf/dailies/serve/main.ts @@ -0,0 +1,5 @@ +import { serve } from "../env.ts"; + +if (import.meta.main) { + await serve(); +} diff --git a/deno.jsonc b/deno.jsonc index 021a4f4..3bcdbdf 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -6,7 +6,9 @@ "all": "deno task udd && deno lint && deno fmt", "test": "deno test --unstable", "start": "deno run -A --unstable main.ts", - "ngrok": "ngrok http 8080" + "ngrok": "ngrok http 8080", + "cf:push": "deno run -A cf/dailies/push/main.ts", + "cf:serve": "deno run -A cf/dailies/serve/main.ts" }, "imports": { "lc-dailies/": "./" diff --git a/lib/denoflare/denoflare.ts b/lib/denoflare/denoflare.ts new file mode 100644 index 0000000..0d9117b --- /dev/null +++ b/lib/denoflare/denoflare.ts @@ -0,0 +1,68 @@ +const DENOFLARE_CONFIG_FILENAME = ".denoflare"; + +/** + * DENOFLARE_VERSION_TAG is the version tag of denoflare. + */ +export const DENOFLARE_VERSION_TAG = "v0.5.12"; + +/** + * DenoflareOptions is the options for denoflare. + */ +export interface DenoflareOptions { + versionTag: string; + scriptName: string; + path: string; + cfAccountID: string; + cfAPIToken: string; + localPort: number; + args: string[]; +} + +/** + * denoflare is a helper for interfacing with the denoflare CLI. + * + * See: https://denoflare.dev/cli/ + */ +export async function denoflare(options: DenoflareOptions) { + const moduleURL = `https://deno.land/x/denoflare@${options.versionTag}`; + const config = { + $schema: `${moduleURL}/common/config.schema.json`, + scripts: { + [options.scriptName]: { + path: options.path, + localPort: options.localPort, + }, + }, + profiles: { + profile: { + accountId: options.cfAccountID, + apiToken: options.cfAPIToken, + }, + }, + }; + await Deno.writeTextFile( + DENOFLARE_CONFIG_FILENAME, + JSON.stringify(config), + ); + + try { + // Create a child process running denoflare CLI. + const child = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "-A", + "--unstable", + `${moduleURL}/cli/cli.ts`, + ...options.args, + ], + stdin: "piped", + stdout: "piped", + }).spawn(); + + // Pipe the child process stdout to stdout. + await child.stdout.pipeTo(Deno.stdout.writable); + } finally { + // Delete the temporary config file. + await Deno.remove(DENOFLARE_CONFIG_FILENAME); + } +} diff --git a/lib/denoflare/mod.ts b/lib/denoflare/mod.ts new file mode 100644 index 0000000..660ca8b --- /dev/null +++ b/lib/denoflare/mod.ts @@ -0,0 +1 @@ +export * from "./denoflare.ts";