commit 0ba9b9f95c044de65d62e270fbae2a034fafa085 Author: matamune Date: Tue May 12 21:06:22 2026 +0000 Initial git webhooks service diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 0000000..b69ebdf --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,20 @@ +name: check + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + check: + runs-on: bun + steps: + - uses: actions/checkout@v4 + - name: Install + run: bun install --frozen-lockfile + - name: Typecheck + run: bun run check:types + - name: Test + run: bun test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3309191 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +dist +coverage +*.log +.env +data/*.jsonl +data/events +data/jobs +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..34bc700 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM oven/bun:1.3.11-alpine + +WORKDIR /app + +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile + +COPY tsconfig.json ./ +COPY src ./src + +ENV HOST=0.0.0.0 +ENV PORT=3000 +ENV DATA_DIR=/app/data + +RUN mkdir -p /app/data + +EXPOSE 3000 +CMD ["bun", "src/server.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cab3fc2 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# git-webhooks + +Containerized Bun service for GitHub and jojo.build webhooks. + +## Endpoints + +```text +GET /healthz +POST /git-webhooks/jojo +POST /git-webhooks/github +``` + +## Environment + +```text +HOST=0.0.0.0 +PORT=3000 +DATA_DIR=/app/data +JOJO_WEBHOOK_SECRET=... +GITHUB_WEBHOOK_SECRET=... +``` + +## Development + +```bash +bun install +bun run check +bun run dev +``` + +Accepted webhook events are appended to `DATA_DIR/events.jsonl`; queued work +items are appended to `DATA_DIR/jobs.jsonl`. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..51b29cb --- /dev/null +++ b/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@peezy.tech/git-webhooks", + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b4a2c13 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "@peezy.tech/git-webhooks", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/server.ts", + "start": "bun src/server.ts", + "test": "bun test", + "check:types": "tsc --noEmit", + "check": "bun run check:types && bun test" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.3" + } +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..5bf5002 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,23 @@ +export function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + ...init, + headers: { + "content-type": "application/json; charset=utf-8", + ...init.headers, + }, + }); +} + +export function textResponse(body: string, init: ResponseInit = {}): Response { + return new Response(body, { + ...init, + headers: { + "content-type": "text/plain; charset=utf-8", + ...init.headers, + }, + }); +} + +export function methodNotAllowed(): Response { + return jsonResponse({ error: "method_not_allowed" }, { status: 405 }); +} diff --git a/src/providers/github.ts b/src/providers/github.ts new file mode 100644 index 0000000..927e1f6 --- /dev/null +++ b/src/providers/github.ts @@ -0,0 +1,75 @@ +import type { GitWebhookEvent, NormalizedEventName } from "../types"; + +type GitHubPayload = { + action?: string; + zen?: string; + hook_id?: number; + ref?: string; + before?: string; + after?: string; + repository?: { + name?: string; + full_name?: string; + clone_url?: string; + ssh_url?: string; + default_branch?: string; + owner?: { + login?: string; + name?: string; + }; + }; + sender?: { + login?: string; + html_url?: string; + }; +}; + +function normalizeEventName(providerEvent: string, payload: GitHubPayload): NormalizedEventName { + if (providerEvent === "ping" || payload.zen || payload.hook_id) return "ping"; + if (providerEvent === "push") return "push"; + if (providerEvent === "pull_request") return "pull_request"; + if (providerEvent === "workflow_run") return "workflow_run"; + if (providerEvent === "release") return "release"; + return "unknown"; +} + +export function normalizeGithubEvent(input: { + providerEvent: string; + deliveryId: string; + receivedAt: string; + payload: GitHubPayload; +}): GitWebhookEvent { + const { payload } = input; + const repoOwner = payload.repository?.owner?.login ?? payload.repository?.owner?.name; + const repoName = payload.repository?.name; + const fullName = payload.repository?.full_name ?? (repoOwner && repoName ? `${repoOwner}/${repoName}` : undefined); + + return { + provider: "github", + event: normalizeEventName(input.providerEvent, payload), + providerEvent: input.providerEvent, + deliveryId: input.deliveryId, + receivedAt: input.receivedAt, + repo: repoOwner && repoName && fullName + ? { + owner: repoOwner, + name: repoName, + fullName, + cloneUrl: payload.repository?.clone_url, + sshUrl: payload.repository?.ssh_url, + defaultBranch: payload.repository?.default_branch, + } + : undefined, + sender: payload.sender?.login + ? { + username: payload.sender.login, + htmlUrl: payload.sender.html_url, + } + : undefined, + ref: payload.ref, + before: payload.before, + after: payload.after, + action: payload.action, + raw: payload, + }; +} diff --git a/src/providers/jojo.ts b/src/providers/jojo.ts new file mode 100644 index 0000000..95b729e --- /dev/null +++ b/src/providers/jojo.ts @@ -0,0 +1,76 @@ +import type { GitWebhookEvent, NormalizedEventName } from "../types"; + +type JojoPayload = { + action?: string; + ref?: string; + before?: string; + after?: string; + repository?: { + name?: string; + full_name?: string; + clone_url?: string; + ssh_url?: string; + default_branch?: string; + owner?: { + login?: string; + username?: string; + name?: string; + }; + }; + sender?: { + login?: string; + username?: string; + html_url?: string; + }; +}; + +function normalizeEventName(providerEvent: string): NormalizedEventName { + if (providerEvent === "ping") return "ping"; + if (providerEvent === "push") return "push"; + if (providerEvent === "pull_request") return "pull_request"; + if (providerEvent === "workflow_run") return "workflow_run"; + if (providerEvent === "release") return "release"; + return "unknown"; +} + +export function normalizeJojoEvent(input: { + providerEvent: string; + deliveryId: string; + receivedAt: string; + payload: JojoPayload; +}): GitWebhookEvent { + const { payload } = input; + const repoOwner = payload.repository?.owner?.login ?? payload.repository?.owner?.username ?? payload.repository?.owner?.name; + const repoName = payload.repository?.name; + const fullName = payload.repository?.full_name ?? (repoOwner && repoName ? `${repoOwner}/${repoName}` : undefined); + const senderUsername = payload.sender?.login ?? payload.sender?.username; + + return { + provider: "jojo", + event: normalizeEventName(input.providerEvent), + providerEvent: input.providerEvent, + deliveryId: input.deliveryId, + receivedAt: input.receivedAt, + repo: repoOwner && repoName && fullName + ? { + owner: repoOwner, + name: repoName, + fullName, + cloneUrl: payload.repository?.clone_url, + sshUrl: payload.repository?.ssh_url, + defaultBranch: payload.repository?.default_branch, + } + : undefined, + sender: senderUsername + ? { + username: senderUsername, + htmlUrl: payload.sender?.html_url, + } + : undefined, + ref: payload.ref, + before: payload.before, + after: payload.after, + action: payload.action, + raw: payload, + }; +} diff --git a/src/queue.ts b/src/queue.ts new file mode 100644 index 0000000..aed0ae1 --- /dev/null +++ b/src/queue.ts @@ -0,0 +1,43 @@ +import { appendFile, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { GitWebhookEvent, QueuedJob } from "./types"; + +async function appendJsonLine(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + await appendFile(path, `${JSON.stringify(value)}\n`, "utf8"); +} + +export class EventStore { + readonly eventsPath: string; + readonly jobsPath: string; + + constructor(dataDir: string) { + this.eventsPath = join(dataDir, "events.jsonl"); + this.jobsPath = join(dataDir, "jobs.jsonl"); + } + + async appendEvent(event: GitWebhookEvent): Promise { + await appendJsonLine(this.eventsPath, event); + } + + async appendJob(job: QueuedJob): Promise { + await appendJsonLine(this.jobsPath, job); + } +} + +export function jobForEvent(event: GitWebhookEvent): QueuedJob | null { + if (event.event !== "push" || event.ref !== "refs/heads/main" || !event.repo || !event.after) { + return null; + } + + return { + id: `${event.provider}:${event.deliveryId}:main_push`, + kind: "main_push", + provider: event.provider, + repoFullName: event.repo.fullName, + ref: event.ref, + sha: event.after, + deliveryId: event.deliveryId, + createdAt: event.receivedAt, + }; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..5f88950 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,114 @@ +import { randomUUID } from "node:crypto"; +import { jsonResponse, methodNotAllowed, textResponse } from "./http"; +import { normalizeGithubEvent } from "./providers/github"; +import { normalizeJojoEvent } from "./providers/jojo"; +import { EventStore, jobForEvent } from "./queue"; +import { verifyGithubSignature, verifyJojoSignature } from "./signatures"; +import type { GitWebhookEvent } from "./types"; + +const maxBodyBytes = 1024 * 1024; + +export type ServerConfig = { + githubSecret: string; + jojoSecret: string; + dataDir: string; +}; + +function getHeader(headers: Headers, name: string, fallback: string): string { + return headers.get(name) ?? fallback; +} + +async function parseJsonBody(request: Request): Promise<{ body: string; payload: unknown } | Response> { + const body = await request.text(); + if (body.length > maxBodyBytes) { + return jsonResponse({ error: "payload_too_large" }, { status: 413 }); + } + + try { + return { body, payload: JSON.parse(body) }; + } catch { + return jsonResponse({ error: "invalid_json" }, { status: 400 }); + } +} + +async function persistAcceptedEvent(store: EventStore, event: GitWebhookEvent): Promise { + await store.appendEvent(event); + const job = jobForEvent(event); + if (job) { + await store.appendJob(job); + } + console.log(JSON.stringify({ type: "webhook.accepted", provider: event.provider, event: event.event, deliveryId: event.deliveryId, job: job?.id })); + return jsonResponse({ status: event.event === "ping" ? "ok" : "accepted", event: event.event, deliveryId: event.deliveryId }, { + status: event.event === "ping" ? 200 : 202, + }); +} + +async function handleGithub(request: Request, config: ServerConfig, store: EventStore): Promise { + if (request.method !== "POST") return methodNotAllowed(); + const parsed = await parseJsonBody(request); + if (parsed instanceof Response) return parsed; + + const verified = await verifyGithubSignature(config.githubSecret, parsed.body, request.headers.get("x-hub-signature-256")); + if (!verified) return jsonResponse({ error: "invalid_signature" }, { status: 401 }); + + const event = normalizeGithubEvent({ + providerEvent: getHeader(request.headers, "x-github-event", "unknown"), + deliveryId: getHeader(request.headers, "x-github-delivery", randomUUID()), + receivedAt: new Date().toISOString(), + payload: parsed.payload as never, + }); + return persistAcceptedEvent(store, event); +} + +async function handleJojo(request: Request, config: ServerConfig, store: EventStore): Promise { + if (request.method !== "POST") return methodNotAllowed(); + const parsed = await parseJsonBody(request); + if (parsed instanceof Response) return parsed; + + const verified = await verifyJojoSignature(config.jojoSecret, parsed.body, request.headers); + if (!verified) return jsonResponse({ error: "invalid_signature" }, { status: 401 }); + + const event = normalizeJojoEvent({ + providerEvent: getHeader(request.headers, "x-forgejo-event", request.headers.get("x-gitea-event") ?? "unknown"), + deliveryId: getHeader(request.headers, "x-forgejo-delivery", request.headers.get("x-gitea-delivery") ?? randomUUID()), + receivedAt: new Date().toISOString(), + payload: parsed.payload as never, + }); + return persistAcceptedEvent(store, event); +} + +export function createHandler(config: ServerConfig): (request: Request) => Promise | Response { + const store = new EventStore(config.dataDir); + + return async (request: Request) => { + const url = new URL(request.url); + if (url.pathname === "/healthz") { + return textResponse("ok\n"); + } + if (url.pathname === "/git-webhooks/github") { + return handleGithub(request, config, store); + } + if (url.pathname === "/git-webhooks/jojo") { + return handleJojo(request, config, store); + } + return jsonResponse({ error: "not_found" }, { status: 404 }); + }; +} + +if (import.meta.main) { + const port = Number(process.env.PORT ?? "3000"); + const hostname = process.env.HOST ?? "0.0.0.0"; + const config: ServerConfig = { + githubSecret: process.env.GITHUB_WEBHOOK_SECRET ?? "", + jojoSecret: process.env.JOJO_WEBHOOK_SECRET ?? "", + dataDir: process.env.DATA_DIR ?? "./data", + }; + + Bun.serve({ + hostname, + port, + fetch: createHandler(config), + }); + + console.log(JSON.stringify({ type: "server.started", hostname, port })); +} diff --git a/src/signatures.ts b/src/signatures.ts new file mode 100644 index 0000000..3058ebc --- /dev/null +++ b/src/signatures.ts @@ -0,0 +1,62 @@ +import { timingSafeEqual } from "node:crypto"; + +const encoder = new TextEncoder(); + +function timingSafeEqualHex(a: string, b: string): boolean { + const left = Buffer.from(a, "hex"); + const right = Buffer.from(b, "hex"); + if (left.length !== right.length) { + return false; + } + return timingSafeEqual(left, right); +} + +export async function hmacSha256Hex(secret: string, body: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body)); + return Buffer.from(signature).toString("hex"); +} + +export async function verifyGithubSignature( + secret: string, + body: string, + signatureHeader: string | null, +): Promise { + if (!secret || !signatureHeader?.startsWith("sha256=")) { + return false; + } + const expected = await hmacSha256Hex(secret, body); + const actual = signatureHeader.slice("sha256=".length).toLowerCase(); + return /^[0-9a-f]{64}$/.test(actual) && timingSafeEqualHex(actual, expected); +} + +export async function verifyJojoSignature( + secret: string, + body: string, + headers: Headers, +): Promise { + if (!secret) { + return false; + } + + const candidates = [ + headers.get("x-forgejo-signature-256"), + headers.get("x-forgejo-signature"), + headers.get("x-gitea-signature-256"), + headers.get("x-gitea-signature"), + ].filter((value): value is string => Boolean(value)); + + const expected = await hmacSha256Hex(secret, body); + return candidates.some((candidate) => { + const actual = candidate.startsWith("sha256=") + ? candidate.slice("sha256=".length).toLowerCase() + : candidate.toLowerCase(); + return /^[0-9a-f]{64}$/.test(actual) && timingSafeEqualHex(actual, expected); + }); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7466953 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,45 @@ +export type Provider = "jojo" | "github"; + +export type NormalizedEventName = + | "ping" + | "push" + | "pull_request" + | "workflow_run" + | "release" + | "unknown"; + +export type GitWebhookEvent = { + provider: Provider; + event: NormalizedEventName; + providerEvent: string; + deliveryId: string; + receivedAt: string; + repo?: { + owner: string; + name: string; + fullName: string; + cloneUrl?: string; + sshUrl?: string; + defaultBranch?: string; + }; + sender?: { + username: string; + htmlUrl?: string; + }; + ref?: string; + before?: string; + after?: string; + action?: string; + raw: unknown; +}; + +export type QueuedJob = { + id: string; + kind: "main_push"; + provider: Provider; + repoFullName: string; + ref: string; + sha: string; + deliveryId: string; + createdAt: string; +}; diff --git a/test/providers.test.ts b/test/providers.test.ts new file mode 100644 index 0000000..5eacfee --- /dev/null +++ b/test/providers.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeGithubEvent } from "../src/providers/github"; +import { normalizeJojoEvent } from "../src/providers/jojo"; + +const repository = { + name: "git-webhooks", + full_name: "peezy-tech/git-webhooks", + clone_url: "https://example.test/peezy-tech/git-webhooks.git", + ssh_url: "git@example.test:peezy-tech/git-webhooks.git", + default_branch: "main", + owner: { login: "peezy-tech" }, +}; + +describe("provider normalization", () => { + test("normalizes GitHub push events", () => { + const event = normalizeGithubEvent({ + providerEvent: "push", + deliveryId: "delivery-1", + receivedAt: "2026-05-12T00:00:00.000Z", + payload: { + ref: "refs/heads/main", + before: "before", + after: "after", + repository, + sender: { login: "peezy", html_url: "https://github.com/peezy" }, + }, + }); + + expect(event.provider).toBe("github"); + expect(event.event).toBe("push"); + expect(event.repo?.fullName).toBe("peezy-tech/git-webhooks"); + expect(event.sender?.username).toBe("peezy"); + }); + + test("normalizes jojo push events", () => { + const event = normalizeJojoEvent({ + providerEvent: "push", + deliveryId: "delivery-2", + receivedAt: "2026-05-12T00:00:00.000Z", + payload: { + ref: "refs/heads/main", + before: "before", + after: "after", + repository, + sender: { username: "peezy", html_url: "https://jojo.build/peezy" }, + }, + }); + + expect(event.provider).toBe("jojo"); + expect(event.event).toBe("push"); + expect(event.repo?.owner).toBe("peezy-tech"); + expect(event.sender?.username).toBe("peezy"); + }); +}); diff --git a/test/server.test.ts b/test/server.test.ts new file mode 100644 index 0000000..05fceb0 --- /dev/null +++ b/test/server.test.ts @@ -0,0 +1,60 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, test } from "bun:test"; +import { createHandler } from "../src/server"; +import { hmacSha256Hex } from "../src/signatures"; + +async function signedRequest(path: string, provider: "github" | "jojo", secret: string, body: unknown): Promise { + const raw = JSON.stringify(body); + const digest = await hmacSha256Hex(secret, raw); + const headers: Record = { "content-type": "application/json" }; + if (provider === "github") { + headers["x-hub-signature-256"] = `sha256=${digest}`; + headers["x-github-event"] = "push"; + headers["x-github-delivery"] = "github-delivery"; + } else { + headers["x-forgejo-signature-256"] = `sha256=${digest}`; + headers["x-forgejo-event"] = "push"; + headers["x-forgejo-delivery"] = "jojo-delivery"; + } + return new Request(`http://localhost${path}`, { method: "POST", headers, body: raw }); +} + +describe("server", () => { + test("healthz returns ok", async () => { + const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir: await mkdtemp(join(tmpdir(), "git-webhooks-")) }); + const response = await handler(new Request("http://localhost/healthz")); + expect(response.status).toBe(200); + expect(await response.text()).toBe("ok\n"); + }); + + test("rejects invalid signatures", async () => { + const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir: await mkdtemp(join(tmpdir(), "git-webhooks-")) }); + const response = await handler(new Request("http://localhost/git-webhooks/github", { + method: "POST", + headers: { "x-hub-signature-256": "sha256=bad" }, + body: "{}", + })); + expect(response.status).toBe(401); + }); + + test("accepts jojo main pushes and queues a job", async () => { + const dataDir = await mkdtemp(join(tmpdir(), "git-webhooks-")); + const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir }); + const request = await signedRequest("/git-webhooks/jojo", "jojo", "jojo", { + ref: "refs/heads/main", + after: "abc123", + repository: { + name: "git-webhooks", + full_name: "peezy-tech/git-webhooks", + owner: { username: "peezy-tech" }, + }, + }); + + const response = await handler(request); + expect(response.status).toBe(202); + expect(await readFile(join(dataDir, "events.jsonl"), "utf8")).toContain("\"provider\":\"jojo\""); + expect(await readFile(join(dataDir, "jobs.jsonl"), "utf8")).toContain("\"kind\":\"main_push\""); + }); +}); diff --git a/test/signatures.test.ts b/test/signatures.test.ts new file mode 100644 index 0000000..5214422 --- /dev/null +++ b/test/signatures.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { hmacSha256Hex, verifyGithubSignature, verifyJojoSignature } from "../src/signatures"; + +describe("webhook signatures", () => { + test("verifies GitHub sha256 signatures", async () => { + const body = JSON.stringify({ ok: true }); + const digest = await hmacSha256Hex("secret", body); + + expect(await verifyGithubSignature("secret", body, `sha256=${digest}`)).toBe(true); + expect(await verifyGithubSignature("wrong", body, `sha256=${digest}`)).toBe(false); + }); + + test("verifies jojo Forgejo/Gitea signature headers", async () => { + const body = JSON.stringify({ ok: true }); + const digest = await hmacSha256Hex("secret", body); + const headers = new Headers({ "x-forgejo-signature-256": `sha256=${digest}` }); + + expect(await verifyJojoSignature("secret", body, headers)).toBe(true); + expect(await verifyJojoSignature("wrong", body, headers)).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e0fe374 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "types": ["bun-types"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}