diff --git a/.claude/launch.json b/.claude/launch.json
index 6527ef8..d407871 100644
--- a/.claude/launch.json
+++ b/.claude/launch.json
@@ -7,6 +7,12 @@
"runtimeArgs": ["run", "dev"],
"port": 3000,
"autoPort": true
+ },
+ {
+ "name": "worker",
+ "runtimeExecutable": "npx",
+ "runtimeArgs": ["wrangler", "dev", "--port", "8787"],
+ "port": 8787
}
]
}
diff --git a/.dev.vars.example b/.dev.vars.example
new file mode 100644
index 0000000..fb8ffbe
--- /dev/null
+++ b/.dev.vars.example
@@ -0,0 +1,11 @@
+# Local secrets for `npm run preview` (wrangler dev). Copy to .dev.vars —
+# which git ignores. Production secrets are set in Cloudflare, never here.
+
+# Cloudflare's test secret: always passes, and pairs with the test site key
+# (NEXT_PUBLIC_TURNSTILE_SITE_KEY in .env.local).
+TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
+
+# Log the email instead of sending it, so no Resend key is needed locally.
+# Remove it and set RESEND_API_KEY to send a real test email.
+CONTACT_DRY_RUN=1
+# RESEND_API_KEY=re_...
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e2bc24b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+# --- GitHub (products) ---
+# Optional: a GitHub personal access token (no scopes needed for public repos).
+# Lifts the unauthenticated API rate limit so product cards always load.
+GITHUB_TOKEN=
+
+# --- Contact form (local only) ---
+# Cloudflare's test site key, which always passes. Production uses the real
+# key in src/config/site.ts; don't set this in Cloudflare's build settings.
+NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
diff --git a/.gitignore b/.gitignore
index 9fac8ff..de7dd83 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,12 +32,15 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
# vercel
.vercel
-# cloudflare
+# cloudflare — local Worker secrets live in .dev.vars
.wrangler/
+.dev.vars*
+!.dev.vars.example
# local agent settings (the shared .claude/launch.json is committed)
.claude/settings.local.json
diff --git a/.prettierignore b/.prettierignore
index f9a2070..4ded772 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,5 +4,8 @@ node_modules
package-lock.json
next-env.d.ts
public
+.wrangler
+# Generated by `npm run cf-typegen`.
+worker/worker-configuration.d.ts
# Essays are prose: the author's line breaks stay the author's.
content
diff --git a/README.md b/README.md
index 6b5f524..c0cd1c8 100644
--- a/README.md
+++ b/README.md
@@ -70,8 +70,13 @@ npm run format # apply Prettier
unauthenticated requests are limited to 60 an hour per IP, and a failed
request drops that card from the build (with a `[products]` warning in the
build log).
-- **Contact** — no backend: the form opens the visitor's own mail app with the
- message filled in, and says so.
+- **Contact** — the form posts to `/api/contact`, the one piece of the site
+ that isn't a static file: a small Worker script (`worker/`) that checks the
+ request, verifies a Cloudflare Turnstile token (spam protection, invisible
+ unless Cloudflare wants a click) and emails the message through Resend, with
+ the visitor's address as Reply-To. A honeypot field drops simple bots. Until
+ a Turnstile site key is set in `src/config/site.ts`, the page shows a plain
+ email link instead of the form. Setup: see _Contact form_ below.
## Writing
@@ -123,6 +128,7 @@ What an article can use:
| What | Where |
| ----------------------------------- | --------------------------- |
| Name, email, social links, comments | `src/config/site.ts` |
+| Contact form recipient and sender | `wrangler.jsonc` (`vars`) |
| Which repos show as products | `src/config/products.ts` |
| Colours, radius, shadows | `src/app/theme.css` |
| Type scale, motion, code styling | `src/app/globals.css` |
@@ -139,6 +145,41 @@ Worker static assets, with `404.html` for unknown paths.
to read public repositories.
3. Attach the `omercelik.dev` custom domain to the Worker.
+## Contact form — Resend and Turnstile
+
+Both are free at this scale (Resend: 3,000 emails a month, 100 a day;
+Turnstile: unlimited). Mail for `omercelik.dev` stays on Google Workspace:
+Resend only sends, from the `mail.omercelik.dev` subdomain, so the root MX
+records are never touched.
+
+1. **Resend** — at resend.com, add the domain `mail.omercelik.dev` and add
+ the DNS records it lists in Cloudflare DNS (SPF and DKIM TXT records and
+ an MX record, all under `mail.omercelik.dev`). Wait for _Verified_, then
+ create an API key with _Sending access_ for that domain only.
+2. **Turnstile** — in the Cloudflare dashboard, _Turnstile → Add widget_:
+ hostname `omercelik.dev`, mode _Managed_. Copy the **site key** into
+ `turnstileSiteKey` in `src/config/site.ts` (it's public) and keep the
+ **secret key** for the next step.
+3. **Secrets** — in the Worker's _Settings → Variables and Secrets_, add
+ `RESEND_API_KEY` and `TURNSTILE_SECRET_KEY` as _Secret_ (or run
+ `npx wrangler secret put RESEND_API_KEY`). They never go in the repo.
+4. Deploy, send yourself a message from `/contact`, and check Resend's
+ _Emails_ log if it doesn't arrive.
+
+Without the secrets the endpoint answers 503 and the form points visitors to
+the email address, so a half-finished setup never loses a message silently.
+
+**Try it locally** — copy `.env.example` to `.env.local` and
+`.dev.vars.example` to `.dev.vars` (Cloudflare's test keys, and a dry run that
+logs the email instead of sending it), then:
+
+```bash
+npm run preview # static build + wrangler dev → http://localhost:8787
+```
+
+`npm run dev` serves the pages but not the Worker, so the form can't send
+there. After changing `wrangler.jsonc`, run `npm run cf-typegen`.
+
## After the first deploy — search engines
The site ships everything search engines read (sitemap, canonicals,
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 33bc2a6..864c908 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -12,6 +12,9 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
+ // Generated by `npm run cf-typegen`, and wrangler's local state.
+ "worker/worker-configuration.d.ts",
+ ".wrangler/**",
]),
{
files: ["**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}"],
diff --git a/package-lock.json b/package-lock.json
index af83267..2d8755a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,7 +35,11 @@
"prettier": "^3.9.6",
"tailwindcss": "^4",
"typescript": "^5",
- "vitest": "^5.0.0"
+ "vitest": "^5.0.0",
+ "wrangler": "^4.131.1"
+ },
+ "engines": {
+ "node": ">=22.12"
}
},
"node_modules/@alloc/quick-lru": {
@@ -238,88 +242,665 @@
"parser": "bin/babel-parser.js"
},
"engines": {
- "node": ">=6.0.0"
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@cloudflare/kv-asset-handler": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
+ "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@cloudflare/unenv-preset": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
+ "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "peerDependencies": {
+ "unenv": "2.0.0-rc.24",
+ "workerd": ">1.20260305.0 <2.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "workerd": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260911.1.tgz",
+ "integrity": "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260911.1.tgz",
+ "integrity": "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260911.1.tgz",
+ "integrity": "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260911.1.tgz",
+ "integrity": "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260911.1.tgz",
+ "integrity": "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/@babel/template": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
- "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
"engines": {
- "node": ">=6.9.0"
+ "node": ">=18"
}
},
- "node_modules/@babel/traverse": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
- "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.7",
- "@babel/helper-globals": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7",
- "debug": "^4.3.1"
- },
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
"engines": {
- "node": ">=6.9.0"
+ "node": ">=18"
}
},
- "node_modules/@babel/types": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
- "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7"
- },
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
"engines": {
- "node": ">=6.9.0"
+ "node": ">=18"
}
},
- "node_modules/@emnapi/core": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
- "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
"license": "MIT",
"optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.1",
- "tslib": "^2.4.0"
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/@emnapi/runtime": {
- "version": "1.11.3",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
- "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
"license": "MIT",
"optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/@emnapi/wasi-threads": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
- "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
"license": "MIT",
"optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
@@ -566,8 +1147,8 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "devOptional": true,
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -1775,6 +2356,48 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/@poppinss/colors": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^4.1.5"
+ }
+ },
+ "node_modules/@poppinss/dumper": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@sindresorhus/is": "^7.0.2",
+ "supports-color": "^10.0.0"
+ }
+ },
+ "node_modules/@poppinss/dumper/node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/@poppinss/exception": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
@@ -2184,6 +2807,26 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
+ "node_modules/@sindresorhus/is": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@speed-highlight/core": {
+ "version": "1.2.24",
+ "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz",
+ "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz",
@@ -3868,6 +4511,13 @@
"node": ">=6.0.0"
}
},
+ "node_modules/blake3-wasm": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
@@ -4143,6 +4793,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4397,6 +5061,16 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/error-stack-parser-es": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -4635,6 +5309,48 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5348,7 +6064,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@@ -6642,6 +7357,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/language-subtag-registry": {
"version": "0.3.23",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
@@ -8096,6 +8821,24 @@
"node": ">=8.6"
}
},
+ "node_modules/miniflare": {
+ "version": "5.20260911.0-alpha",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260911.0-alpha.tgz",
+ "integrity": "sha512-CRieJmvHx+7rNqnA5SKdsYsER6rfkUIE/jruIUw+fLhsQ4sORfuMtr3+FQzsQ9/y8lhk061V4Fl1DFdHiyBB6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "sharp": "0.35.4",
+ "undici": "7.29.0",
+ "workerd": "1.20260911.1",
+ "ws": "8.21.0",
+ "youch": "4.1.0-beta.10"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -8677,6 +9420,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -9404,8 +10161,8 @@
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
+ "devOptional": true,
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
@@ -9454,8 +10211,8 @@
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "devOptional": true,
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -10199,6 +10956,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@@ -10206,6 +10973,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/unenv": {
+ "version": "2.0.0-rc.24",
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pathe": "^2.0.3"
+ }
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -11133,6 +11910,85 @@
"node": ">=0.10.0"
}
},
+ "node_modules/workerd": {
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260911.1.tgz",
+ "integrity": "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20260911.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260911.1",
+ "@cloudflare/workerd-linux-64": "1.20260911.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260911.1",
+ "@cloudflare/workerd-windows-64": "1.20260911.1"
+ }
+ },
+ "node_modules/wrangler": {
+ "version": "4.131.1",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.131.1.tgz",
+ "integrity": "sha512-1u5FMdJAn6UOcL02cVsIITcnHrk6mC7N+RF10EkVhPL18R/o9g5BZb4PCjByL+3AsRP5wQpppCIPHhYPRmIJwg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.5.0",
+ "@cloudflare/unenv-preset": "2.16.1",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.28.1",
+ "miniflare": "5.20260911.0-alpha",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.24",
+ "workerd": "1.20260911.1"
+ },
+ "bin": {
+ "cf-wrangler": "bin/cf-wrangler.js",
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^5.20260911.1"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -11168,6 +12024,31 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/youch": {
+ "version": "4.1.0-beta.10",
+ "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
+ "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@poppinss/dumper": "^0.6.4",
+ "@speed-highlight/core": "^1.2.7",
+ "cookie": "^1.0.2",
+ "youch-core": "^0.3.3"
+ }
+ },
+ "node_modules/youch-core": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
+ "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/exception": "^1.2.2",
+ "error-stack-parser-es": "^1.0.5"
+ }
+ },
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
diff --git a/package.json b/package.json
index 2ca6a00..19a9656 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,9 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
- "typecheck": "tsc --noEmit",
+ "typecheck": "tsc --noEmit && tsc --noEmit -p worker",
+ "preview": "next build && wrangler dev",
+ "cf-typegen": "wrangler types worker/worker-configuration.d.ts",
"test": "vitest run",
"format": "prettier --write .",
"format:check": "prettier --check .",
@@ -41,7 +43,8 @@
"prettier": "^3.9.6",
"tailwindcss": "^4",
"typescript": "^5",
- "vitest": "^5.0.0"
+ "vitest": "^5.0.0",
+ "wrangler": "^4.131.1"
},
"engines": {
"node": ">=22.12"
diff --git a/src/components/contact/contact-form.tsx b/src/components/contact/contact-form.tsx
index a46f3bc..01ed0e2 100644
--- a/src/components/contact/contact-form.tsx
+++ b/src/components/contact/contact-form.tsx
@@ -1,7 +1,7 @@
"use client";
-import { type FormEvent } from "react";
-import { Mail } from "lucide-react";
+import { useEffect, useRef, useState, type FormEvent } from "react";
+import { Check, Mail, Send } from "lucide-react";
import { useTranslations } from "next-intl";
import { buttonClass } from "@/components/ui/button";
import { site } from "@/config/site";
@@ -9,63 +9,287 @@ import { site } from "@/config/site";
const inputClass =
"w-full rounded-[var(--radius-lg)] border border-input bg-background px-3.5 py-2.5 text-field text-foreground outline-none transition-colors placeholder:text-faint focus:border-brand-accent";
-/** No backend, no dependency, and honest about it: submitting opens the
- * visitor's own mail app with the message filled in. The reply address is
- * whatever account they send from, so the form doesn't ask for it. */
+/** The public Turnstile site key. NEXT_PUBLIC_TURNSTILE_SITE_KEY overrides it
+ * for local runs with Cloudflare's test key (.env.example). */
+const SITE_KEY =
+ process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || site.contact.turnstileSiteKey;
+
+/** The contact form posts to the site's Worker (worker/contact.ts), which
+ * checks the Turnstile token and emails the message. Until a site key is
+ * configured it's a plain email link — never a form that can't send. */
export function ContactForm() {
+ return SITE_KEY ? : ;
+}
+
+function DirectEmail() {
const t = useTranslations("contact");
+ return (
+
+ );
+}
+
+type Turnstile = {
+ render(el: HTMLElement, options: Record): string;
+ reset(widgetId: string): void;
+ remove(widgetId: string): void;
+};
+
+declare global {
+ interface Window {
+ turnstile?: Turnstile;
+ }
+}
+
+const TURNSTILE_SRC =
+ "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
+
+let turnstileLoader: Promise | null = null;
+
+/** Load Cloudflare's widget script once, on the contact page only. */
+function loadTurnstile(): Promise {
+ turnstileLoader ??= new Promise((resolve, reject) => {
+ if (window.turnstile) return resolve(window.turnstile);
+ const script = document.createElement("script");
+ script.src = TURNSTILE_SRC;
+ script.async = true;
+ script.onload = () =>
+ window.turnstile
+ ? resolve(window.turnstile)
+ : reject(new Error("Turnstile did not load"));
+ script.onerror = () => {
+ turnstileLoader = null; // let a later visit try again
+ reject(new Error("Turnstile did not load"));
+ };
+ document.head.appendChild(script);
+ });
+ return turnstileLoader;
+}
+
+type ErrorCode = "invalid" | "verification" | "failed";
+type Status =
+ | { kind: "idle" }
+ | { kind: "sending" }
+ | { kind: "sent"; name: string }
+ | { kind: "error"; code: ErrorCode };
+
+const ERROR_KEY = {
+ invalid: "errorInvalid",
+ verification: "errorVerification",
+} as const;
- function onSubmit(e: FormEvent) {
+function SendingForm({ siteKey }: { siteKey: string }) {
+ const t = useTranslations("contact");
+ const widgetEl = useRef(null);
+ const widgetId = useRef(null);
+ const [token, setToken] = useState(null);
+ const [status, setStatus] = useState({ kind: "idle" });
+
+ useEffect(() => {
+ let cancelled = false;
+ loadTurnstile()
+ .then((turnstile) => {
+ if (cancelled || !widgetEl.current) return;
+ widgetId.current = turnstile.render(widgetEl.current, {
+ sitekey: siteKey,
+ action: "contact",
+ // Invisible unless Cloudflare wants the visitor to click a box.
+ appearance: "interaction-only",
+ size: "flexible",
+ theme:
+ document.documentElement.dataset.theme === "dark"
+ ? "dark"
+ : "light",
+ language: "en",
+ callback: (value: string) => setToken(value),
+ "expired-callback": () => setToken(null),
+ "error-callback": () => setToken(null),
+ });
+ })
+ .catch(() => setStatus({ kind: "error", code: "verification" }));
+
+ return () => {
+ cancelled = true;
+ if (widgetId.current) window.turnstile?.remove(widgetId.current);
+ widgetId.current = null;
+ };
+ }, [siteKey]);
+
+ async function onSubmit(e: FormEvent) {
e.preventDefault();
- const data = new FormData(e.currentTarget);
+ const form = e.currentTarget;
+ const data = new FormData(form);
const name = String(data.get("name") ?? "").trim();
- const message = String(data.get("message") ?? "");
- const subject = encodeURIComponent(t("subject", { name }));
- const body = encodeURIComponent(`${message}\n\n— ${name}`);
- // A literal mailto: so it reads (to people and to Next's lint) as the
- // external link it is, not an in-app navigation.
- window.location.assign(
- `mailto:${site.email}?subject=${subject}&body=${body}`,
- );
+ setStatus({ kind: "sending" });
+
+ let next: Status;
+ try {
+ const res = await fetch(site.contact.endpoint, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ name,
+ email: data.get("email"),
+ message: data.get("message"),
+ botcheck: data.get("botcheck"),
+ token,
+ }),
+ });
+ if (res.ok) {
+ form.reset();
+ next = { kind: "sent", name };
+ } else {
+ const body = (await res.json().catch(() => ({}))) as {
+ error?: string;
+ };
+ next = {
+ kind: "error",
+ code:
+ body.error === "invalid" || body.error === "verification"
+ ? body.error
+ : "failed",
+ };
+ }
+ } catch {
+ next = { kind: "error", code: "failed" };
+ }
+
+ // A token passes one check only: get a fresh one for the next message.
+ setToken(null);
+ if (widgetId.current) window.turnstile?.reset(widgetId.current);
+ setStatus(next);
}
+ const sending = status.kind === "sending";
+
return (
-
+ <>
+ {status.kind === "sent" && (
+
+
+
+
+
{t("sentTitle")}
+
+ {t("sentBody", { name: status.name })}
+
+
+
+ )}
+
+ {/* Hidden rather than unmounted once sent, so the Turnstile widget
+ stays in place for the next message. */}
+
+ >
);
}
diff --git a/src/components/writings/comments.tsx b/src/components/writings/comments.tsx
index e4896f2..677702c 100644
--- a/src/components/writings/comments.tsx
+++ b/src/components/writings/comments.tsx
@@ -33,7 +33,11 @@ export function Comments({ term, lang }: { term: string; lang: string }) {
s.setAttribute("data-category-id", c.categoryId);
s.setAttribute("data-mapping", "specific");
s.setAttribute("data-term", term);
- s.setAttribute("data-strict", "0");
+ // Exact matching: GitHub's search is fuzzy, so without it
+ // "golden-paths-1" could pick up the thread for "golden-paths-10".
+ // Has to be on before the first thread exists — threads created without
+ // it don't carry the hash strict mode matches on.
+ s.setAttribute("data-strict", "1");
s.setAttribute("data-reactions-enabled", "1");
s.setAttribute("data-emit-metadata", "0");
s.setAttribute("data-input-position", "bottom");
diff --git a/src/config/site.ts b/src/config/site.ts
index 35602f1..4b19d35 100644
--- a/src/config/site.ts
+++ b/src/config/site.ts
@@ -22,4 +22,11 @@ export const site = {
category: "Announcements",
categoryId: "DIC_kwDOSnPY_84DBDCw",
},
+ /** The contact form, sent by worker/contact.ts. The Turnstile site key is
+ * public (it ships in the page), so it lives here; the secret key stays in
+ * Cloudflare. Left empty, the page offers a plain email link instead. */
+ contact: {
+ endpoint: "/api/contact",
+ turnstileSiteKey: "",
+ },
} as const;
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index eb165de..48a1cbe 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -110,12 +110,21 @@
"title": "Get in touch",
"subtitle": "Questions, ideas, or feedback on the work — my inbox is open, and I read everything.",
"nameLabel": "Name",
+ "emailLabel": "Email",
"messageLabel": "Message",
"namePlaceholder": "Your name",
+ "emailPlaceholder": "you@company.com",
"messagePlaceholder": "What would you like to build or discuss?",
- "subject": "Message from {name}",
- "submit": "Open in your mail app",
- "note": "Nothing is sent from this page — it opens your own mail app with the message filled in.",
+ "submit": "Send message",
+ "sending": "Sending…",
+ "note": "Your message comes straight to my inbox, and your address is only used to reply. Spam protection by Cloudflare Turnstile.",
+ "sentTitle": "Message sent",
+ "sentBody": "Thanks, {name} — it's in my inbox, and I'll reply to the address you gave.",
+ "sendAnother": "Send another message",
+ "errorInvalid": "Please check the fields: your name, a valid email and a message of at least 10 characters.",
+ "errorVerification": "The spam check didn't go through. Please wait a moment and try again.",
+ "errorFailed": "Something went wrong on my side. Please email me at {address} instead.",
+ "direct": "Write to me directly — I read everything.",
"orReach": "Or reach me directly"
},
"cta": {
diff --git a/tsconfig.json b/tsconfig.json
index cf9c65d..586d3e3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
- "exclude": ["node_modules"]
+ "exclude": ["node_modules", "worker"]
}
diff --git a/vitest.config.mts b/vitest.config.mts
index 1d8e145..cd3e4dd 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -7,6 +7,6 @@ export default defineConfig({
},
test: {
environment: "node",
- include: ["src/**/*.test.ts"],
+ include: ["src/**/*.test.ts", "worker/**/*.test.ts"],
},
});
diff --git a/worker/contact.test.ts b/worker/contact.test.ts
new file mode 100644
index 0000000..e6755be
--- /dev/null
+++ b/worker/contact.test.ts
@@ -0,0 +1,261 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ buildEmail,
+ handleContact,
+ parseContact,
+ type ContactEnv,
+} from "./contact";
+
+const ORIGIN = "https://omercelik.dev";
+
+const env: ContactEnv = {
+ CONTACT_TO: "omer@omercelik.dev",
+ CONTACT_FROM: "omercelik.dev ",
+ RESEND_API_KEY: "re_test",
+ TURNSTILE_SECRET_KEY: "turnstile_secret",
+};
+
+const valid = {
+ name: "Ada Lovelace",
+ email: "ada@example.com",
+ message: "Hello — a question about specdrift.",
+ botcheck: "",
+ token: "turnstile-token",
+};
+
+function post(body: unknown, headers: Record = {}) {
+ return new Request(`${ORIGIN}/api/contact`, {
+ method: "POST",
+ headers: {
+ origin: ORIGIN,
+ "content-type": "application/json",
+ ...headers,
+ },
+ body: typeof body === "string" ? body : JSON.stringify(body),
+ });
+}
+
+/** Stands in for Turnstile's siteverify and Resend's API. */
+function fakeFetch({
+ verify = { success: true, hostname: "omercelik.dev", action: "contact" },
+ resendStatus = 200,
+}: {
+ verify?: Record;
+ resendStatus?: number;
+} = {}) {
+ const calls: { url: string; init: RequestInit }[] = [];
+ const fn = async (input: string | URL | Request, init?: RequestInit) => {
+ const url = String(input);
+ calls.push({ url, init: init ?? {} });
+ if (url.includes("siteverify")) return Response.json(verify);
+ return new Response(
+ resendStatus === 200 ? '{"id":"email_1"}' : '{"message":"rejected"}',
+ { status: resendStatus },
+ );
+ };
+ return { fetcher: fn as unknown as typeof fetch, calls };
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("parseContact", () => {
+ it("accepts a valid message and tidies it", () => {
+ const parsed = parseContact({
+ ...valid,
+ name: " Ada \r\n Lovelace ",
+ message: " Line one\r\nLine two ",
+ });
+ expect(parsed).toEqual({
+ ok: true,
+ token: "turnstile-token",
+ message: {
+ name: "Ada Lovelace",
+ email: "ada@example.com",
+ message: "Line one\nLine two",
+ },
+ });
+ });
+
+ it.each([
+ ["a missing name", { name: "" }],
+ ["a one-letter name", { name: "A" }],
+ ["an address without a domain", { email: "ada@" }],
+ ["two addresses", { email: "ada@example.com, eve@example.com" }],
+ ["an address with a header break", { email: "ada@example.com\nBcc: x" }],
+ ["a short message", { message: "Hi" }],
+ ["a missing token", { token: "" }],
+ ["a non-string field", { message: 42 }],
+ ])("rejects %s", (_, override) => {
+ expect(parseContact({ ...valid, ...override })).toEqual({
+ ok: false,
+ reason: "invalid",
+ });
+ });
+
+ it("flags a filled honeypot as spam", () => {
+ expect(parseContact({ ...valid, botcheck: "https://spam" })).toEqual({
+ ok: false,
+ reason: "spam",
+ });
+ });
+});
+
+describe("buildEmail", () => {
+ it("goes to the owner, with the visitor as Reply-To", () => {
+ const email = buildEmail(env, {
+ name: "Ada",
+ email: "ada@example.com",
+ message: "Hello there, friend.",
+ });
+ expect(email).toMatchObject({
+ from: env.CONTACT_FROM,
+ to: ["omer@omercelik.dev"],
+ reply_to: "ada@example.com",
+ subject: "Message from Ada · omercelik.dev",
+ });
+ expect(email.text).toContain("Hello there, friend.");
+ expect(email.text).toContain("From: Ada ");
+ });
+});
+
+describe("handleContact", () => {
+ it("sends a verified message through Resend", async () => {
+ const { fetcher, calls } = fakeFetch();
+ const res = await handleContact(post(valid), env, fetcher);
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ ok: true });
+ expect(calls.map((c) => c.url)).toEqual([
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
+ "https://api.resend.com/emails",
+ ]);
+ const verify = JSON.parse(String(calls[0].init.body));
+ expect(verify).toMatchObject({
+ secret: "turnstile_secret",
+ response: "turnstile-token",
+ });
+ const resend = calls[1].init;
+ expect(new Headers(resend.headers).get("authorization")).toBe(
+ "Bearer re_test",
+ );
+ expect(JSON.parse(String(resend.body))).toMatchObject({
+ to: ["omer@omercelik.dev"],
+ reply_to: "ada@example.com",
+ });
+ });
+
+ it("only accepts POST", async () => {
+ const res = await handleContact(
+ new Request(`${ORIGIN}/api/contact`),
+ env,
+ fakeFetch().fetcher,
+ );
+ expect(res.status).toBe(405);
+ expect(res.headers.get("allow")).toBe("POST");
+ });
+
+ it("refuses posts from other sites", async () => {
+ const { fetcher, calls } = fakeFetch();
+ const res = await handleContact(
+ post(valid, { origin: "https://evil.example" }),
+ env,
+ fetcher,
+ );
+ expect(res.status).toBe(403);
+ expect(calls).toHaveLength(0);
+ });
+
+ it("rejects a body that isn't JSON", async () => {
+ const res = await handleContact(post("name=Ada"), env, fakeFetch().fetcher);
+ expect(res.status).toBe(400);
+ expect(await res.json()).toEqual({ error: "invalid" });
+ });
+
+ it("rejects an oversized body", async () => {
+ const res = await handleContact(
+ post({ ...valid, message: "x".repeat(40_000) }),
+ env,
+ fakeFetch().fetcher,
+ );
+ expect(res.status).toBe(413);
+ });
+
+ it("rejects invalid fields without calling anything", async () => {
+ const { fetcher, calls } = fakeFetch();
+ const res = await handleContact(
+ post({ ...valid, email: "nope" }),
+ env,
+ fetcher,
+ );
+ expect(res.status).toBe(400);
+ expect(calls).toHaveLength(0);
+ });
+
+ it("answers a bot like a person but sends nothing", async () => {
+ const { fetcher, calls } = fakeFetch();
+ const res = await handleContact(
+ post({ ...valid, botcheck: "filled" }),
+ env,
+ fetcher,
+ );
+ expect(res.status).toBe(200);
+ expect(calls).toHaveLength(0);
+ });
+
+ it("fails closed when a secret is missing", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ const { fetcher, calls } = fakeFetch();
+ const res = await handleContact(
+ post(valid),
+ { ...env, RESEND_API_KEY: undefined },
+ fetcher,
+ );
+ expect(res.status).toBe(503);
+ expect(await res.json()).toEqual({ error: "unavailable" });
+ expect(calls).toHaveLength(0);
+ });
+
+ it.each([
+ ["a failed challenge", { success: false }],
+ [
+ "a token from another site",
+ { success: true, hostname: "evil.example", action: "contact" },
+ ],
+ [
+ "a token for another action",
+ { success: true, hostname: "omercelik.dev", action: "login" },
+ ],
+ ])("refuses %s", async (_, verify) => {
+ const { fetcher, calls } = fakeFetch({ verify });
+ const res = await handleContact(post(valid), env, fetcher);
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual({ error: "verification" });
+ expect(calls).toHaveLength(1); // never reached Resend
+ });
+
+ it("reports a Resend failure", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ const { fetcher } = fakeFetch({ resendStatus: 500 });
+ const res = await handleContact(post(valid), env, fetcher);
+ expect(res.status).toBe(502);
+ expect(await res.json()).toEqual({ error: "failed" });
+ });
+
+ it("logs instead of sending on a dry run", async () => {
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
+ // Cloudflare's test keys: a placeholder hostname and no action.
+ const { fetcher, calls } = fakeFetch({
+ verify: { success: true, hostname: "example.com" },
+ });
+ const res = await handleContact(
+ post(valid),
+ { ...env, RESEND_API_KEY: undefined, CONTACT_DRY_RUN: "1" },
+ fetcher,
+ );
+ expect(res.status).toBe(200);
+ expect(calls).toHaveLength(1);
+ expect(log).toHaveBeenCalledOnce();
+ });
+});
diff --git a/worker/contact.ts b/worker/contact.ts
new file mode 100644
index 0000000..d91bc1b
--- /dev/null
+++ b/worker/contact.ts
@@ -0,0 +1,199 @@
+/** The contact form's backend: POST /api/contact. Checks the request,
+ * verifies the Turnstile token, then sends the message to the site owner
+ * through Resend with the visitor's address as Reply-To. */
+
+export interface ContactEnv {
+ /** Recipient, e.g. "omer@omercelik.dev" (wrangler.jsonc vars). */
+ CONTACT_TO: string;
+ /** Sender on a domain verified in Resend (wrangler.jsonc vars). */
+ CONTACT_FROM: string;
+ /** Secrets — set in Cloudflare, never in the repo. */
+ RESEND_API_KEY?: string;
+ TURNSTILE_SECRET_KEY?: string;
+ /** "1" for local runs: log the email instead of sending it, and skip the
+ * hostname check Cloudflare's test keys can't pass. */
+ CONTACT_DRY_RUN?: string;
+}
+
+export interface ContactMessage {
+ name: string;
+ email: string;
+ message: string;
+}
+
+export type ContactError =
+ "method" | "origin" | "invalid" | "verification" | "unavailable" | "failed";
+
+export const LIMITS = {
+ name: { min: 2, max: 100 },
+ email: { max: 254 },
+ message: { min: 10, max: 5000 },
+ /** Request body, in bytes — well above the largest valid message. */
+ body: 32_000,
+} as const;
+
+/** The widget's action name; the server checks the token was issued for it. */
+export const TURNSTILE_ACTION = "contact";
+
+const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
+const RESEND = "https://api.resend.com/emails";
+
+// No spaces, angle brackets, quotes, commas or semicolons: nothing that could
+// turn one address into several or break out of a header.
+const EMAIL = /^[^\s@<>()",;:\\]+@[^\s@<>()",;:\\]+\.[^\s@<>()",;:\\]{2,}$/;
+
+// Control characters (line breaks included) never belong in a name or a
+// subject line.
+const CONTROL = /[\u0000-\u001f\u007f]+/g;
+
+type Parsed =
+ | { ok: true; message: ContactMessage; token: string }
+ | { ok: false; reason: "invalid" | "spam" };
+
+/** Validate the JSON body the form sends. `botcheck` is a honeypot: a field
+ * people never see, so anything in it came from a bot. */
+export function parseContact(body: unknown): Parsed {
+ if (!body || typeof body !== "object")
+ return { ok: false, reason: "invalid" };
+ const b = body as Record;
+ const str = (v: unknown) => (typeof v === "string" ? v : "");
+
+ if (str(b.botcheck).trim() !== "") return { ok: false, reason: "spam" };
+
+ const name = str(b.name).replace(CONTROL, " ").replace(/\s+/g, " ").trim();
+ const email = str(b.email).trim();
+ const message = str(b.message).replace(/\r\n?/g, "\n").trim();
+ const token = str(b.token);
+
+ const valid =
+ name.length >= LIMITS.name.min &&
+ name.length <= LIMITS.name.max &&
+ email.length <= LIMITS.email.max &&
+ EMAIL.test(email) &&
+ message.length >= LIMITS.message.min &&
+ message.length <= LIMITS.message.max &&
+ token !== "";
+
+ return valid
+ ? { ok: true, message: { name, email, message }, token }
+ : { ok: false, reason: "invalid" };
+}
+
+/** The email as Resend's API takes it. Plain text only: nothing the visitor
+ * typed is ever interpreted as HTML. */
+export function buildEmail(env: ContactEnv, m: ContactMessage) {
+ return {
+ from: env.CONTACT_FROM,
+ to: [env.CONTACT_TO],
+ reply_to: m.email,
+ subject: `Message from ${m.name} · omercelik.dev`,
+ text: `${m.message}\n\n—\nFrom: ${m.name} <${m.email}>\nSent from the contact form on omercelik.dev. Reply to this email to answer.`,
+ };
+}
+
+function json(status: number, body: { ok: true } | { error: ContactError }) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ "cache-control": "no-store",
+ },
+ });
+}
+
+const fail = (status: number, error: ContactError) => json(status, { error });
+
+async function verifyTurnstile(
+ token: string,
+ request: Request,
+ env: ContactEnv,
+ fetcher: typeof fetch,
+): Promise {
+ const res = await fetcher(SITEVERIFY, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ secret: env.TURNSTILE_SECRET_KEY,
+ response: token,
+ remoteip: request.headers.get("cf-connecting-ip") ?? undefined,
+ }),
+ });
+ if (!res.ok) return false;
+ const outcome = (await res.json()) as {
+ success?: boolean;
+ hostname?: string;
+ action?: string;
+ };
+ if (!outcome.success) return false;
+ // Test keys report a placeholder hostname and no action; real tokens must
+ // have been issued on this site, for this form.
+ if (env.CONTACT_DRY_RUN === "1") return true;
+ return (
+ outcome.hostname === new URL(request.url).hostname &&
+ outcome.action === TURNSTILE_ACTION
+ );
+}
+
+export async function handleContact(
+ request: Request,
+ env: ContactEnv,
+ fetcher: typeof fetch = fetch,
+): Promise {
+ if (request.method !== "POST") {
+ const res = fail(405, "method");
+ res.headers.set("allow", "POST");
+ return res;
+ }
+
+ // Only the site's own pages post here.
+ if (request.headers.get("origin") !== new URL(request.url).origin) {
+ return fail(403, "origin");
+ }
+
+ const raw = await request.text();
+ if (raw.length > LIMITS.body) return fail(413, "invalid");
+ let body: unknown;
+ try {
+ body = JSON.parse(raw);
+ } catch {
+ return fail(400, "invalid");
+ }
+
+ const parsed = parseContact(body);
+ // A bot that filled the honeypot gets the same answer as a person, so it
+ // has nothing to learn from — but nothing is sent.
+ if (!parsed.ok && parsed.reason === "spam") return json(200, { ok: true });
+ if (!parsed.ok) return fail(400, "invalid");
+
+ const dryRun = env.CONTACT_DRY_RUN === "1";
+ if (!env.TURNSTILE_SECRET_KEY || (!env.RESEND_API_KEY && !dryRun)) {
+ console.error(
+ "[contact] TURNSTILE_SECRET_KEY or RESEND_API_KEY is not set",
+ );
+ return fail(503, "unavailable");
+ }
+
+ if (!(await verifyTurnstile(parsed.token, request, env, fetcher))) {
+ return fail(403, "verification");
+ }
+
+ const email = buildEmail(env, parsed.message);
+ if (dryRun) {
+ console.log("[contact] dry run — not sent:", JSON.stringify(email));
+ return json(200, { ok: true });
+ }
+
+ const res = await fetcher(RESEND, {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${env.RESEND_API_KEY}`,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify(email),
+ });
+ if (!res.ok) {
+ console.error(`[contact] Resend ${res.status}: ${await res.text()}`);
+ return fail(502, "failed");
+ }
+ return json(200, { ok: true });
+}
diff --git a/worker/index.ts b/worker/index.ts
new file mode 100644
index 0000000..29704ee
--- /dev/null
+++ b/worker/index.ts
@@ -0,0 +1,17 @@
+import { handleContact, type ContactEnv } from "./contact";
+
+type Env = ContactEnv & { ASSETS: Fetcher };
+
+/** The site is static files (./out) served as Worker assets. Only /api/*
+ * reaches this script (run_worker_first in wrangler.jsonc); everything else
+ * is answered by the asset server directly. */
+export default {
+ async fetch(request, env) {
+ const { pathname } = new URL(request.url);
+ if (pathname === "/api/contact") return handleContact(request, env);
+ if (pathname.startsWith("/api/")) {
+ return new Response("Not found", { status: 404 });
+ }
+ return env.ASSETS.fetch(request);
+ },
+} satisfies ExportedHandler;
diff --git a/worker/tsconfig.json b/worker/tsconfig.json
new file mode 100644
index 0000000..6b68b8d
--- /dev/null
+++ b/worker/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "types": ["./worker-configuration.d.ts"],
+ "strict": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "skipLibCheck": true
+ },
+ "include": ["./**/*.ts"]
+}
diff --git a/worker/worker-configuration.d.ts b/worker/worker-configuration.d.ts
new file mode 100644
index 0000000..ee54ade
--- /dev/null
+++ b/worker/worker-configuration.d.ts
@@ -0,0 +1,15398 @@
+/* eslint-disable */
+// Generated by Wrangler by running `wrangler types worker/worker-configuration.d.ts` (hash: 1e4a72d83c57832a36f77cea990ca69c)
+// Runtime types generated with workerd@1.20260911.1 2026-09-01
+interface __BaseEnv_Env {
+ ASSETS: Fetcher;
+ CONTACT_TO: "omer@omercelik.dev";
+ CONTACT_FROM: "omercelik.dev ";
+ TURNSTILE_SECRET_KEY: string;
+ CONTACT_DRY_RUN: string;
+}
+declare namespace Cloudflare {
+ interface GlobalProps {
+ mainModule: typeof import("./index");
+ }
+ interface Env extends __BaseEnv_Env {}
+}
+interface Env extends __BaseEnv_Env {}
+type StringifyValues> = {
+ [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
+};
+declare namespace NodeJS {
+ interface ProcessEnv extends StringifyValues> {}
+}
+
+// Begin runtime types
+/*! *****************************************************************************
+Copyright (c) Cloudflare. All rights reserved.
+Copyright (c) Microsoft Corporation. All rights reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at http://www.apache.org/licenses/LICENSE-2.0
+THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
+WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
+MERCHANTABLITY OR NON-INFRINGEMENT.
+See the Apache Version 2.0 License for specific language governing permissions
+and limitations under the License.
+***************************************************************************** */
+/* eslint-disable */
+// noinspection JSUnusedGlobalSymbols
+declare var onmessage: never;
+/**
+ * The **`DOMException`** interface represents an abnormal event (called an exception) that occurs as a result of calling a method or accessing a property of a web API. This is how error conditions are described in web APIs.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException)
+ */
+declare class DOMException extends Error {
+ constructor(message?: string, name?: string);
+ /**
+ * The **`message`** read-only property of the DOMException interface returns a string representing a message or description associated with the given error name.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message)
+ */
+ readonly message: string;
+ /**
+ * The **`name`** read-only property of the DOMException interface returns a string that contains one of the strings associated with an error name.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name)
+ */
+ readonly name: string;
+ /**
+ * The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or 0 if none match.
+ * @deprecated
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)
+ */
+ readonly code: number;
+ static readonly INDEX_SIZE_ERR: number;
+ static readonly DOMSTRING_SIZE_ERR: number;
+ static readonly HIERARCHY_REQUEST_ERR: number;
+ static readonly WRONG_DOCUMENT_ERR: number;
+ static readonly INVALID_CHARACTER_ERR: number;
+ static readonly NO_DATA_ALLOWED_ERR: number;
+ static readonly NO_MODIFICATION_ALLOWED_ERR: number;
+ static readonly NOT_FOUND_ERR: number;
+ static readonly NOT_SUPPORTED_ERR: number;
+ static readonly INUSE_ATTRIBUTE_ERR: number;
+ static readonly INVALID_STATE_ERR: number;
+ static readonly SYNTAX_ERR: number;
+ static readonly INVALID_MODIFICATION_ERR: number;
+ static readonly NAMESPACE_ERR: number;
+ static readonly INVALID_ACCESS_ERR: number;
+ static readonly VALIDATION_ERR: number;
+ static readonly TYPE_MISMATCH_ERR: number;
+ static readonly SECURITY_ERR: number;
+ static readonly NETWORK_ERR: number;
+ static readonly ABORT_ERR: number;
+ static readonly URL_MISMATCH_ERR: number;
+ static readonly QUOTA_EXCEEDED_ERR: number;
+ static readonly TIMEOUT_ERR: number;
+ static readonly INVALID_NODE_TYPE_ERR: number;
+ static readonly DATA_CLONE_ERR: number;
+ get stack(): any;
+ set stack(value: any);
+}
+type WorkerGlobalScopeEventMap = {
+ fetch: FetchEvent;
+ scheduled: ScheduledEvent;
+ queue: QueueEvent;
+ unhandledrejection: PromiseRejectionEvent;
+ rejectionhandled: PromiseRejectionEvent;
+};
+declare abstract class WorkerGlobalScope extends EventTarget {
+ EventTarget: typeof EventTarget;
+}
+/* The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). *
+ * The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox).
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console)
+ */
+interface Console {
+ "assert"(condition?: boolean, ...data: any[]): void;
+ /**
+ * The **`console.clear()`** static method clears the console if possible.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static)
+ */
+ clear(): void;
+ /**
+ * The **`console.count()`** static method logs the number of times that this particular call to count() has been called.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static)
+ */
+ count(label?: string): void;
+ /**
+ * The **`console.countReset()`** static method resets counter used with console.count().
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static)
+ */
+ countReset(label?: string): void;
+ /**
+ * The **`console.debug()`** static method outputs a message to the console at the "debug" log level. The message is only displayed to the user if the console is configured to display debug output. In most cases, the log level is configured within the console UI. This log level might correspond to the Debug or Verbose log level.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static)
+ */
+ debug(...data: any[]): void;
+ /**
+ * The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object. In browser consoles, the output is presented as a hierarchical listing with disclosure triangles that let you see the contents of child objects.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static)
+ */
+ dir(item?: any, options?: any): void;
+ /**
+ * The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element. If it is not possible to display as an element the JavaScript Object view is shown instead. The output is presented as a hierarchical listing of expandable nodes that let you see the contents of child nodes.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static)
+ */
+ dirxml(...data: any[]): void;
+ /**
+ * The **`console.error()`** static method outputs a message to the console at the "error" log level. The message is only displayed to the user if the console is configured to display error output. In most cases, the log level is configured within the console UI. The message may be formatted as an error, with red colors and call stack information.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static)
+ */
+ error(...data: any[]): void;
+ /**
+ * The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console.groupEnd() is called.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static)
+ */
+ group(...data: any[]): void;
+ /**
+ * The **`console.groupCollapsed()`** static method creates a new inline group in the console. Unlike console.group(), however, the new group is created collapsed. The user will need to use the disclosure button next to it to expand it, revealing the entries created in the group.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static)
+ */
+ groupCollapsed(...data: any[]): void;
+ /**
+ * The **`console.groupEnd()`** static method exits the current inline group in the console. See Using groups in the console in the console documentation for details and examples.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static)
+ */
+ groupEnd(): void;
+ /**
+ * The **`console.info()`** static method outputs a message to the console at the "info" log level. The message is only displayed to the user if the console is configured to display info output. In most cases, the log level is configured within the console UI. The message may receive special formatting, such as a small "i" icon next to it.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static)
+ */
+ info(...data: any[]): void;
+ /**
+ * The **`console.log()`** static method outputs a message to the console.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
+ */
+ log(...data: any[]): void;
+ /**
+ * The **`console.table()`** static method displays tabular data as a table.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static)
+ */
+ table(tabularData?: any, properties?: string[]): void;
+ /**
+ * The **`console.time()`** static method starts a timer you can use to track how long an operation takes. You give each timer a unique name, and may have up to 10,000 timers running on a given page. When you call console.timeEnd() with the same name, the browser will output the time, in milliseconds, that elapsed since the timer was started.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static)
+ */
+ time(label?: string): void;
+ /**
+ * The **`console.timeEnd()`** static method stops a timer that was previously started by calling console.time().
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static)
+ */
+ timeEnd(label?: string): void;
+ /**
+ * The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console.time().
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static)
+ */
+ timeLog(label?: string, ...data: any[]): void;
+ /* The **`console.timeStamp()`** static method adds a single marker to the browser's Performance tool (Firefox bug 1387528, Chrome). This lets you correlate a point in your code with the other events recorded in the timeline, such as layout and paint events. */
+ timeStamp(label?: string): void;
+ /**
+ * The **`console.trace()`** static method outputs a stack trace to the console.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static)
+ */
+ trace(...data: any[]): void;
+ /**
+ * The **`console.warn()`** static method outputs a warning message to the console at the "warning" log level. The message is only displayed to the user if the console is configured to display warning output. In most cases, the log level is configured within the console UI. The message may receive special formatting, such as yellow colors and a warning icon.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static)
+ */
+ warn(...data: any[]): void;
+}
+declare const console: Console;
+type BufferSource = ArrayBufferView | ArrayBuffer;
+type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array;
+declare namespace WebAssembly {
+ class CompileError extends Error {
+ constructor(message?: string);
+ }
+ class RuntimeError extends Error {
+ constructor(message?: string);
+ }
+ type ValueType = "anyfunc" | "externref" | "f32" | "f64" | "i32" | "i64" | "v128";
+ interface GlobalDescriptor {
+ value: ValueType;
+ mutable?: boolean;
+ }
+ class Global {
+ constructor(descriptor: GlobalDescriptor, value?: any);
+ value: any;
+ valueOf(): any;
+ }
+ type ImportValue = ExportValue | number;
+ type ModuleImports = Record;
+ type Imports = Record;
+ type ExportValue = Function | Global | Memory | Table;
+ type Exports = Record;
+ class Instance {
+ constructor(module: Module, imports?: Imports);
+ readonly exports: Exports;
+ }
+ interface MemoryDescriptor {
+ initial: number;
+ maximum?: number;
+ shared?: boolean;
+ }
+ class Memory {
+ constructor(descriptor: MemoryDescriptor);
+ readonly buffer: ArrayBuffer;
+ grow(delta: number): number;
+ }
+ type ImportExportKind = "function" | "global" | "memory" | "table";
+ interface ModuleExportDescriptor {
+ kind: ImportExportKind;
+ name: string;
+ }
+ interface ModuleImportDescriptor {
+ kind: ImportExportKind;
+ module: string;
+ name: string;
+ }
+ abstract class Module {
+ static customSections(module: Module, sectionName: string): ArrayBuffer[];
+ static exports(module: Module): ModuleExportDescriptor[];
+ static imports(module: Module): ModuleImportDescriptor[];
+ }
+ type TableKind = "anyfunc" | "externref";
+ interface TableDescriptor {
+ element: TableKind;
+ initial: number;
+ maximum?: number;
+ }
+ class Table {
+ constructor(descriptor: TableDescriptor, value?: any);
+ readonly length: number;
+ get(index: number): any;
+ grow(delta: number, value?: any): number;
+ set(index: number, value?: any): void;
+ }
+ function instantiate(module: Module, imports?: Imports): Promise;
+ function validate(bytes: BufferSource): boolean;
+}
+/**
+ * The **`ServiceWorkerGlobalScope`** interface of the Service Worker API represents the global execution context of a service worker.
+ * Available only in secure contexts.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope)
+ */
+interface ServiceWorkerGlobalScope extends WorkerGlobalScope {
+ DOMException: typeof DOMException;
+ WorkerGlobalScope: typeof WorkerGlobalScope;
+ btoa(data: string): string;
+ atob(data: string): string;
+ setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
+ setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+ clearTimeout(timeoutId: number | null): void;
+ setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
+ setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+ clearInterval(timeoutId: number | null): void;
+ queueMicrotask(task: Function): void;
+ structuredClone(value: T, options?: StructuredSerializeOptions): T;
+ reportError(error: any): void;
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
+ self: ServiceWorkerGlobalScope;
+ crypto: Crypto;
+ caches: CacheStorage;
+ scheduler: Scheduler;
+ performance: Performance;
+ Cloudflare: Cloudflare;
+ readonly origin: string;
+ Event: typeof Event;
+ ExtendableEvent: typeof ExtendableEvent;
+ CustomEvent: typeof CustomEvent;
+ PromiseRejectionEvent: typeof PromiseRejectionEvent;
+ FetchEvent: typeof FetchEvent;
+ TailEvent: typeof TailEvent;
+ TraceEvent: typeof TailEvent;
+ ScheduledEvent: typeof ScheduledEvent;
+ MessageEvent: typeof MessageEvent;
+ CloseEvent: typeof CloseEvent;
+ ReadableStreamDefaultReader: typeof ReadableStreamDefaultReader;
+ ReadableStreamBYOBReader: typeof ReadableStreamBYOBReader;
+ ReadableStream: typeof ReadableStream;
+ WritableStream: typeof WritableStream;
+ WritableStreamDefaultWriter: typeof WritableStreamDefaultWriter;
+ TransformStream: typeof TransformStream;
+ ByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy;
+ CountQueuingStrategy: typeof CountQueuingStrategy;
+ ErrorEvent: typeof ErrorEvent;
+ MessageChannel: typeof MessageChannel;
+ MessagePort: typeof MessagePort;
+ EventSource: typeof EventSource;
+ ReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest;
+ ReadableStreamDefaultController: typeof ReadableStreamDefaultController;
+ ReadableByteStreamController: typeof ReadableByteStreamController;
+ WritableStreamDefaultController: typeof WritableStreamDefaultController;
+ TransformStreamDefaultController: typeof TransformStreamDefaultController;
+ CompressionStream: typeof CompressionStream;
+ DecompressionStream: typeof DecompressionStream;
+ TextEncoderStream: typeof TextEncoderStream;
+ TextDecoderStream: typeof TextDecoderStream;
+ Headers: typeof Headers;
+ Body: typeof Body;
+ Request: typeof Request;
+ Response: typeof Response;
+ WebSocket: typeof WebSocket;
+ WebSocketPair: typeof WebSocketPair;
+ WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair;
+ AbortController: typeof AbortController;
+ AbortSignal: typeof AbortSignal;
+ TextDecoder: typeof TextDecoder;
+ TextEncoder: typeof TextEncoder;
+ navigator: Navigator;
+ Navigator: typeof Navigator;
+ URL: typeof URL;
+ URLSearchParams: typeof URLSearchParams;
+ URLPattern: typeof URLPattern;
+ Blob: typeof Blob;
+ File: typeof File;
+ FormData: typeof FormData;
+ Crypto: typeof Crypto;
+ SubtleCrypto: typeof SubtleCrypto;
+ CryptoKey: typeof CryptoKey;
+ CacheStorage: typeof CacheStorage;
+ Cache: typeof Cache;
+ FixedLengthStream: typeof FixedLengthStream;
+ IdentityTransformStream: typeof IdentityTransformStream;
+ HTMLRewriter: typeof HTMLRewriter;
+}
+declare function addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void;
+declare function removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void;
+/**
+ * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. The normal event processing rules (including the capturing and optional bubbling phase) also apply to events dispatched manually with dispatchEvent().
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
+ */
+declare function dispatchEvent(event: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap]): boolean;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */
+declare function btoa(data: string): string;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */
+declare function atob(data: string): string;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
+declare function setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
+declare function setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */
+declare function clearTimeout(timeoutId: number | null): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
+declare function setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
+declare function setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */
+declare function clearInterval(timeoutId: number | null): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */
+declare function queueMicrotask(task: Function): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */
+declare function structuredClone(value: T, options?: StructuredSerializeOptions): T;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */
+declare function reportError(error: any): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */
+declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
+declare const self: ServiceWorkerGlobalScope;
+/**
+* The Web Crypto API provides a set of low-level functions for common cryptographic tasks.
+* The Workers runtime implements the full surface of this API, but with some differences in
+* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)
+* compared to those implemented in most browsers.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)
+*/
+declare const crypto: Crypto;
+/**
+* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
+*/
+declare const caches: CacheStorage;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/scheduler) */
+declare const scheduler: Scheduler;
+/**
+* The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
+* as well as timing of subrequests and other operations.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
+*/
+declare const performance: Performance;
+declare const Cloudflare: Cloudflare;
+declare const origin: string;
+declare const navigator: Navigator;
+interface TestController {
+}
+interface ExecutionContext {
+ waitUntil(promise: Promise): void;
+ passThroughOnException(): void;
+ readonly exports: Cloudflare.Exports;
+ readonly props: Props;
+ cache?: CacheContext;
+ readonly access?: CloudflareAccessContext;
+ tracing: Tracing;
+ abort(reason?: any): void;
+}
+type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise;
+type ExportedHandlerConnectHandler = (socket: Socket, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise;
+type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise;
+interface ExportedHandler {
+ fetch?: ExportedHandlerFetchHandler;
+ connect?: ExportedHandlerConnectHandler;
+ tail?: ExportedHandlerTailHandler;
+ trace?: ExportedHandlerTraceHandler;
+ tailStream?: ExportedHandlerTailStreamHandler;
+ scheduled?: ExportedHandlerScheduledHandler;
+ test?: ExportedHandlerTestHandler;
+ email?: EmailExportedHandler;
+ queue?: ExportedHandlerQueueHandler;
+}
+interface StructuredSerializeOptions {
+ transfer?: any[];
+}
+declare abstract class Navigator {
+ sendBeacon(url: string, body?: BodyInit): boolean;
+ readonly userAgent: string;
+ readonly hardwareConcurrency: number;
+ readonly platform: string;
+ readonly language: string;
+ readonly languages: string[];
+}
+interface AlarmInvocationInfo {
+ readonly isRetry: boolean;
+ readonly retryCount: number;
+ readonly scheduledTime: number;
+}
+interface Cloudflare {
+ readonly compatibilityFlags: Record;
+}
+interface CachePurgeError {
+ code: number;
+ message: string;
+}
+interface CachePurgeResult {
+ success: boolean;
+ errors: CachePurgeError[];
+}
+interface CachePurgeOptions {
+ tags?: string[];
+ pathPrefixes?: string[];
+ purgeEverything?: boolean;
+}
+interface CacheContext {
+ purge(options: CachePurgeOptions): Promise;
+}
+interface CloudflareAccessContext {
+ readonly aud: string;
+ getIdentity(): Promise;
+}
+declare abstract class ColoLocalActorNamespace {
+ get(actorId: string): Fetcher;
+}
+interface DurableObject {
+ fetch(request: Request): Response | Promise;
+ connect?(socket: Socket): void | Promise;
+ alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise;
+ webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise;
+ webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise;
+ webSocketError?(ws: WebSocket, error: unknown): void | Promise;
+}
+type DurableObjectStub = Fetcher & {
+ readonly id: DurableObjectId;
+ readonly name?: string;
+};
+interface DurableObjectId {
+ toString(): string;
+ equals(other: DurableObjectId): boolean;
+ readonly name?: string;
+ readonly jurisdiction?: string;
+}
+declare abstract class DurableObjectNamespace {
+ newUniqueId(options?: DurableObjectNamespaceNewUniqueIdOptions): DurableObjectId;
+ idFromName(name: string): DurableObjectId;
+ idFromString(id: string): DurableObjectId;
+ get(id: DurableObjectId, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub;
+ getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub;
+ jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace;
+}
+type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us";
+interface DurableObjectNamespaceNewUniqueIdOptions {
+ jurisdiction?: DurableObjectJurisdiction;
+}
+type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "apac-ne" | "apac-se" | "oc" | "afr" | "me";
+type DurableObjectRoutingMode = "primary-only";
+interface DurableObjectNamespaceGetDurableObjectOptions {
+ locationHint?: DurableObjectLocationHint;
+ routingMode?: DurableObjectRoutingMode;
+}
+interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> {
+}
+interface DurableObjectState {
+ waitUntil(promise: Promise): void;
+ readonly exports: Cloudflare.Exports;
+ readonly props: Props;
+ readonly id: DurableObjectId;
+ readonly storage: DurableObjectStorage;
+ container?: Container;
+ facets: DurableObjectFacets;
+ blockConcurrencyWhile(callback: () => Promise): Promise;
+ acceptWebSocket(ws: WebSocket, tags?: string[]): void;
+ getWebSockets(tag?: string): WebSocket[];
+ setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void;
+ getWebSocketAutoResponse(): WebSocketRequestResponsePair | null;
+ getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null;
+ setHibernatableWebSocketEventTimeout(timeoutMs?: number): void;
+ getHibernatableWebSocketEventTimeout(): number | null;
+ getTags(ws: WebSocket): string[];
+ abort(reason?: string, options?: DurableObjectAbortOptions): void;
+}
+interface DurableObjectTransaction {
+ get(key: string, options?: DurableObjectGetOptions): Promise;
+ get(keys: string[], options?: DurableObjectGetOptions): Promise