diff --git a/.cspell.json b/.cspell.json index ce50f46..a6887a1 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,8 +4,26 @@ "ignorePaths": ["**/*.json", "**/*.css", "node_modules", "**/*.log"], "useGitignore": true, "language": "en", - "words": ["dataurl", "devpool", "fkey", "mswjs", "outdir", "servedir", "supabase", "typebox", "ubiquity-os", - "smee", "tomlify", "hono", "cfworker", "pavlovcik", "ghijklmnopqrstuvwxyz", "GHIJKLMNOPQRSTUVWXYZ", "ubiquityos"], + "words": [ + "dataurl", + "devpool", + "fkey", + "mswjs", + "outdir", + "servedir", + "supabase", + "typebox", + "ubiquity-os", + "smee", + "tomlify", + "hono", + "cfworker", + "workerd", + "pavlovcik", + "ghijklmnopqrstuvwxyz", + "GHIJKLMNOPQRSTUVWXYZ", + "ubiquityos" + ], "dictionaries": ["typescript", "node", "software-terms"], "import": ["@cspell/dict-typescript/cspell-ext.json", "@cspell/dict-node/cspell-ext.json", "@cspell/dict-software-terms"], "ignoreRegExpList": ["[0-9a-fA-F]{6}"] diff --git a/.github/knip.ts b/.github/knip.ts index 549ba26..283ac02 100644 --- a/.github/knip.ts +++ b/.github/knip.ts @@ -1,7 +1,7 @@ import type { KnipConfig } from "knip"; const config: KnipConfig = { - entry: ["src/worker.ts", "deploy/setup-kv-namespace.ts"], + entry: ["src/kernel.ts", "src/cloudflare-worker.ts", "deploy/setup-kv-namespace.ts"], project: ["src/**/*.ts"], ignore: ["jest.config.ts"], ignoreBinaries: ["i", "publish"], diff --git a/bun.lockb b/bun.lockb index 044990f..55320c2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/eslint.config.mjs b/eslint.config.mjs index 37fa4d3..8ed97a9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,7 +9,7 @@ export default tsEslint.config({ "@typescript-eslint": tsEslint.plugin, "check-file": checkFile, }, - ignores: [".github/knip.ts", "**/.wrangler/**", "jest.config.ts", ".husky/**"], + ignores: [".github/knip.ts", "**/.wrangler/**", "jest.config.ts", ".husky/**", "dist/**"], extends: [eslint.configs.recommended, ...tsEslint.configs.recommended, sonarjs.configs.recommended], languageOptions: { parser: tsEslint.parser, diff --git a/package.json b/package.json index e805beb..8838ab5 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@sinclair/typebox": "0.34.3", "@ubiquity-os/plugin-sdk": "^1.1.0", "dotenv": "16.4.5", + "hono": "^4.6.12", "openai": "^4.70.2", "typebox-validators": "0.3.5", "yaml": "2.4.5" diff --git a/src/adapters/cloudflare-worker.ts b/src/adapters/cloudflare-worker.ts new file mode 100644 index 0000000..87c9d93 --- /dev/null +++ b/src/adapters/cloudflare-worker.ts @@ -0,0 +1,3 @@ +import { app } from "../kernel"; + +export default app; diff --git a/src/kernel.ts b/src/kernel.ts new file mode 100644 index 0000000..ad36c4d --- /dev/null +++ b/src/kernel.ts @@ -0,0 +1,94 @@ +import { emitterEventNames } from "@octokit/webhooks"; +import { Value } from "@sinclair/typebox/value"; +import { GitHubEventHandler } from "./github/github-event-handler"; +import { bindHandlers } from "./github/handlers"; +import { Env, envSchema } from "./github/types/env"; +import { EmptyStore } from "./github/utils/kv-store"; +import { WebhookEventName } from "@octokit/webhooks-types"; +import OpenAI from "openai"; +import { Context, Hono, HonoRequest } from "hono"; +import { env as honoEnv, getRuntimeKey } from "hono/adapter"; +import { StatusCode } from "hono/utils/http-status"; + +export const app = new Hono(); + +app.post("/", async (ctx: Context) => { + try { + const env = honoEnv(ctx); + const request = ctx.req; + + validateEnv(env); + const eventName = getEventName(request); + const signatureSha256 = getSignature(request); + const id = getId(request); + const openAiClient = new OpenAI({ + apiKey: env.OPENAI_API_KEY, + }); + const eventHandler = new GitHubEventHandler({ + environment: env.ENVIRONMENT, + webhookSecret: env.APP_WEBHOOK_SECRET, + appId: env.APP_ID, + privateKey: env.APP_PRIVATE_KEY, + pluginChainState: new EmptyStore(), + openAiClient, + }); + bindHandlers(eventHandler); + + // if running in Cloudflare Worker, handle the webhook in the background and return a response immediately + if (getRuntimeKey() === "workerd") { + const waitUntil = ctx.executionCtx.waitUntil; + waitUntil(eventHandler.webhooks.verifyAndReceive({ id, name: eventName, payload: await request.text(), signature: signatureSha256 })); + } else { + await eventHandler.webhooks.verifyAndReceive({ id, name: eventName, payload: await request.text(), signature: signatureSha256 }); + } + return ctx.text("ok\n", 200); + } catch (error) { + return handleUncaughtError(ctx, error); + } +}); + +function handleUncaughtError(ctx: Context, error: unknown) { + console.error(error); + let status = 500; + let errorMessage = "An uncaught error occurred"; + if (error instanceof AggregateError) { + const err = error.errors[0]; + errorMessage = err.message ? `${err.name}: ${err.message}` : `Error: ${errorMessage}`; + status = typeof err.status !== "undefined" && typeof err.status === "number" ? err.status : 500; + } else { + errorMessage = error instanceof Error ? `${error.name}: ${error.message}` : `Error: ${error}`; + } + return ctx.json({ error: errorMessage }, status as StatusCode); +} + +function validateEnv(env: Env): void { + if (!Value.Check(envSchema, env)) { + const errors = [...Value.Errors(envSchema, env)]; + console.error("Invalid environment variables", errors); + throw new Error("Invalid environment variables"); + } +} + +function getEventName(request: HonoRequest): WebhookEventName { + const eventName = request.header("x-github-event"); + if (!eventName || !emitterEventNames.includes(eventName as WebhookEventName)) { + throw new Error(`Unsupported or missing "x-github-event" header value: ${eventName}`); + } + return eventName as WebhookEventName; +} + +function getSignature(request: HonoRequest): string { + const signatureSha256 = request.header("x-hub-signature-256"); + if (!signatureSha256) { + throw new Error(`Missing "x-hub-signature-256" header`); + } + return signatureSha256; +} + +function getId(request: HonoRequest): string { + const id = request.header("x-github-delivery"); + if (!id) { + throw new Error(`Missing "x-github-delivery" header`); + } + return id; +} diff --git a/src/worker.ts b/src/worker.ts deleted file mode 100644 index 711c0b9..0000000 --- a/src/worker.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { emitterEventNames } from "@octokit/webhooks"; -import { Value } from "@sinclair/typebox/value"; -import { GitHubEventHandler } from "./github/github-event-handler"; -import { bindHandlers } from "./github/handlers"; -import { Env, envSchema } from "./github/types/env"; -import { EmptyStore } from "./github/utils/kv-store"; -import { WebhookEventName } from "@octokit/webhooks-types"; -import OpenAI from "openai"; - -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - try { - validateEnv(env); - const eventName = getEventName(request); - const signatureSha256 = getSignature(request); - const id = getId(request); - const openAiClient = new OpenAI({ - apiKey: env.OPENAI_API_KEY, - }); - const eventHandler = new GitHubEventHandler({ - environment: env.ENVIRONMENT, - webhookSecret: env.APP_WEBHOOK_SECRET, - appId: env.APP_ID, - privateKey: env.APP_PRIVATE_KEY, - pluginChainState: new EmptyStore(), - openAiClient, - }); - bindHandlers(eventHandler); - ctx.waitUntil(eventHandler.webhooks.verifyAndReceive({ id, name: eventName, payload: await request.text(), signature: signatureSha256 })); - return new Response("ok\n", { status: 200, headers: { "content-type": "text/plain" } }); - } catch (error) { - return handleUncaughtError(error); - } - }, -}; - -function handleUncaughtError(error: unknown) { - console.error(error); - let status = 500; - let errorMessage = "An uncaught error occurred"; - if (error instanceof AggregateError) { - const err = error.errors[0]; - errorMessage = err.message ? `${err.name}: ${err.message}` : `Error: ${errorMessage}`; - status = typeof err.status !== "undefined" ? err.status : 500; - } else { - errorMessage = error instanceof Error ? `${error.name}: ${error.message}` : `Error: ${error}`; - } - return new Response(JSON.stringify({ error: errorMessage }), { status: status, headers: { "content-type": "application/json" } }); -} - -function validateEnv(env: Env): void { - if (!Value.Check(envSchema, env)) { - const errors = [...Value.Errors(envSchema, env)]; - console.error("Invalid environment variables", errors); - throw new Error("Invalid environment variables"); - } -} - -function getEventName(request: Request): WebhookEventName { - const eventName = request.headers.get("x-github-event"); - if (!eventName || !emitterEventNames.includes(eventName as WebhookEventName)) { - throw new Error(`Unsupported or missing "x-github-event" header value: ${eventName}`); - } - return eventName as WebhookEventName; -} - -function getSignature(request: Request): string { - const signatureSha256 = request.headers.get("x-hub-signature-256"); - if (!signatureSha256) { - throw new Error(`Missing "x-hub-signature-256" header`); - } - return signatureSha256; -} - -function getId(request: Request): string { - const id = request.headers.get("x-github-delivery"); - if (!id) { - throw new Error(`Missing "x-github-delivery" header`); - } - return id; -} diff --git a/tests/dispatch.test.ts b/tests/dispatch.test.ts index 58ac6a1..365b51e 100644 --- a/tests/dispatch.test.ts +++ b/tests/dispatch.test.ts @@ -142,7 +142,16 @@ describe("handleEvent", () => { const payloadString = JSON.stringify(payload); const signature = calculateSignature(payloadString, secret); - const req = new Request("http://localhost:8080", { + process.env = { + ENVIRONMENT: "production", + APP_WEBHOOK_SECRET: secret, + APP_ID: "1", + APP_PRIVATE_KEY: "1234", + OPENAI_API_KEY: "token", + }; + + const app = (await import("../src/kernel")).app; + const res = await app.request("http://localhost:8080", { method: "POST", headers: { "x-github-event": "issue_comment.created", @@ -153,39 +162,8 @@ describe("handleEvent", () => { body: payloadString, }); - const worker = (await import("../src/worker")).default; - // I didn't find a better option to make sure that the non-awaited ctx.waitUntil is resolved - // eslint-disable-next-line no-async-promise-executor - const waitUntilPromise = new Promise(async (resolve) => { - const res = await worker.fetch( - req, - { - ENVIRONMENT: "production", - APP_WEBHOOK_SECRET: secret, - APP_ID: "1", - APP_PRIVATE_KEY: "1234", - PLUGIN_CHAIN_STATE: {} as KVNamespace, - OPENAI_API_KEY: "token", - }, - { - waitUntil(promise: Promise) { - promise - .then(() => { - resolve(); - }) - .catch(() => { - resolve(); - }); - }, - passThroughOnException() {}, - } - ); - - expect(res).toBeTruthy(); - }); - - await waitUntilPromise; - + expect(res).toBeTruthy(); + // 2 calls means the execution didn't break expect(dispatchWorker).toHaveBeenCalledTimes(2); dispatchWorker.mockReset(); diff --git a/tests/main.test.ts b/tests/main.test.ts index e8c782d..e1bb543 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -5,7 +5,7 @@ import { http, HttpResponse } from "msw"; import { GitHubContext } from "../src/github/github-context"; import { GitHubEventHandler } from "../src/github/github-event-handler"; import { getConfig } from "../src/github/utils/config"; -import worker from "../src/worker"; // has to be imported after the mocks +import { app } from "../src/kernel"; // has to be imported after the mocks import { server } from "./__mocks__/node"; import "./__mocks__/webhooks"; @@ -59,24 +59,19 @@ describe("Worker tests", () => { ); }); it("Should fail on missing env variables", async () => { - const req = new Request("http://localhost:8080"); const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => jest.fn()); - const res = await worker.fetch( - req, - { - ENVIRONMENT: "production", - APP_WEBHOOK_SECRET: "", - APP_ID: "", - APP_PRIVATE_KEY: "", - PLUGIN_CHAIN_STATE: {} as KVNamespace, - OPENAI_API_KEY: "token", - }, - { - waitUntil: function () {}, - passThroughOnException: function () {}, - } - ); + process.env = { + ENVIRONMENT: "production", + APP_WEBHOOK_SECRET: "", + APP_ID: "", + APP_PRIVATE_KEY: "", + OPENAI_API_KEY: "token", + }; + const res = await app.request("http://localhost:8080", { + method: "POST", + }); expect(res.status).toEqual(500); + expect(await res.json()).toEqual({ error: "Error: Invalid environment variables" }); consoleSpy.mockReset(); }); diff --git a/wrangler.toml b/wrangler.toml index 7e06401..26b4cd6 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,19 +1,19 @@ name = "ubiquity-os-kernel" -main = "src/worker.ts" +main = "src/adapters/cloudflare-worker.ts" compatibility_date = "2023-12-06" -compatibility_flags = [ "nodejs_compat" ] +compatibility_flags = ["nodejs_compat"] # Prefer this syntax due to a bug in Wrangler: https://github.com/cloudflare/workers-sdk/issues/5634 [env] - [env.dev] - [[env.dev.kv_namespaces]] - binding = "PLUGIN_CHAIN_STATE" - id = "4f7aadc56bef41a7ae2cc8c0582320b3" +[env.dev] +[[env.dev.kv_namespaces]] +binding = "PLUGIN_CHAIN_STATE" +id = "4f7aadc56bef41a7ae2cc8c0582320b3" - [env.production] - [[env.production.kv_namespaces]] - binding = "PLUGIN_CHAIN_STATE" - id = "TO_BE_DEFINED" +[env.production] +[[env.production.kv_namespaces]] +binding = "PLUGIN_CHAIN_STATE" +id = "TO_BE_DEFINED" # Enables Cloudflare Worker Logs [observability]