mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-02 19:32:43 +00:00
shell: fish functions -> ~/.local/bin executables + bash/zsh ports; manage ~/.agents
- 16 cross-shell utilities converted to sh/bash chezmoi executables: ai c2p cp-appid extract_agents fab gdc gh-sonr-org-migrate ghg-clone ghn gho git-summary launch_omp_livediff_overlay lc-elir mdcp mdp pj (ghg-clone not named ghg: unrelated cargo binary owns that name) - cwd/history helpers stay per-shell: yy c cd clip-last + ghg/ghn/gho cd-wrappers in bash/functions/ and zsh/functions/, loaded by rc files - fish keeps only shell-state functions; converted ones now hit PATH - cp-appid ignore gating moved to .local/bin path - dot_agents/skills: 82 skills materialized from dotstate storage so dot_claude skill symlinks resolve on fresh bootstrap; dotstate profile/symlinks.json released .agents/skills .bashrc .zshrc
This commit is contained in:
1 parent
6776366ce6
commit
6406208b35
637 files changed
+100297
-497
No files matched your search
@@ -28,3 +28,5 @@
|
|||||||
{"id":"int-eab7a82b","kind":"field_change","created_at":"2026-08-09T18:58:33.495195245Z","actor":"Prad Nukala","issue_id":"chezmoi-42m","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
|
{"id":"int-eab7a82b","kind":"field_change","created_at":"2026-08-09T18:58:33.495195245Z","actor":"Prad Nukala","issue_id":"chezmoi-42m","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
|
||||||
{"id":"int-45a90136","kind":"field_change","created_at":"2026-08-09T18:58:46.086021951Z","actor":"Prad Nukala","issue_id":"chezmoi-udp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Simulated: every .tmpl rendered in darwin/mac mode without error; darwin ignore file gates entire arch layer (niri/noctalia/fontconfig/mimeapps/systemd/niri-launch/fish niri bits); packages script renders brew-only; gitconfig/yazi render portable. Full apply on real Mac deferred until hardware available."}}
|
{"id":"int-45a90136","kind":"field_change","created_at":"2026-08-09T18:58:46.086021951Z","actor":"Prad Nukala","issue_id":"chezmoi-udp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Simulated: every .tmpl rendered in darwin/mac mode without error; darwin ignore file gates entire arch layer (niri/noctalia/fontconfig/mimeapps/systemd/niri-launch/fish niri bits); packages script renders brew-only; gitconfig/yazi render portable. Full apply on real Mac deferred until hardware available."}}
|
||||||
{"id":"int-bc698341","kind":"field_change","created_at":"2026-08-10T18:09:40.450714596Z","actor":"Prad Nukala","issue_id":"chezmoi-09g","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"bash/zsh managed with starship; aliases single-sourced in .chezmoidata/aliases.yaml, rendered to fish + shell/aliases.sh; verified in all three shells"}}
|
{"id":"int-bc698341","kind":"field_change","created_at":"2026-08-10T18:09:40.450714596Z","actor":"Prad Nukala","issue_id":"chezmoi-09g","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"bash/zsh managed with starship; aliases single-sourced in .chezmoidata/aliases.yaml, rendered to fish + shell/aliases.sh; verified in all three shells"}}
|
||||||
|
{"id":"int-6352d74c","kind":"field_change","created_at":"2026-08-10T19:14:56.357237414Z","actor":"Prad Nukala","issue_id":"chezmoi-288","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"16 cross-shell utilities -> ~/.local/bin executables (ghg renamed ghg-clone: cargo-bin collision); cwd/history helpers ported as per-shell functions in bash/zsh function dirs; fish wrappers migrated to bins"}}
|
||||||
|
{"id":"int-7ce36d07","kind":"field_change","created_at":"2026-08-10T19:14:56.649745176Z","actor":"Prad Nukala","issue_id":"chezmoi-hr0","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"dot_agents/skills materialized (82 skills, secret-scanned); live symlink cut over to real dir; dotstate profile+symlinks.json dropped .agents/.bashrc/.zshrc; storage repo committed+pushed; 0 broken ~/.claude/skills links"}}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
{"_type":"issue","id":"chezmoi-1a0","title":"Secrets audit: grep all candidate content for tokens before any chezmoi add","description":"Grep sk-, ghp_, glpat-, Bearer, token, key = across all files destined for repo. secrets.fish goes to age-encrypted encrypted_ path (key ~/.config/chezmoi/key.txt, manual distribution, document in README). Claude: never .credentials.json.","status":"closed","priority":1,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-09T18:41:50Z","created_by":"Prad Nukala","updated_at":"2026-08-09T18:48:14Z","started_at":"2026-08-09T18:45:28Z","closed_at":"2026-08-09T18:48:14Z","close_reason":"secrets.fish age-encrypted; plaintext purged from git history and remote force-pushed; noctalia/plugins excluded (live ghp_ token); gh hosts.yml excluded; npmrc deferred; rotation recommended","dependency_count":0,"dependent_count":3,"comment_count":0}
|
{"_type":"issue","id":"chezmoi-1a0","title":"Secrets audit: grep all candidate content for tokens before any chezmoi add","description":"Grep sk-, ghp_, glpat-, Bearer, token, key = across all files destined for repo. secrets.fish goes to age-encrypted encrypted_ path (key ~/.config/chezmoi/key.txt, manual distribution, document in README). Claude: never .credentials.json.","status":"closed","priority":1,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-09T18:41:50Z","created_by":"Prad Nukala","updated_at":"2026-08-09T18:48:14Z","started_at":"2026-08-09T18:45:28Z","closed_at":"2026-08-09T18:48:14Z","close_reason":"secrets.fish age-encrypted; plaintext purged from git history and remote force-pushed; noctalia/plugins excluded (live ghp_ token); gh hosts.yml excluded; npmrc deferred; rotation recommended","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||||||
{"_type":"issue","id":"chezmoi-byc","title":"Gating skeleton: .chezmoi.toml.tmpl + .chezmoiignore.tmpl + packages.yaml skeleton","description":"promptOnce name/email/role (desktop|laptop|mac|server); chezmoiignore gates linux-only trees on darwin and vice versa; .chezmoidata/packages.yaml skeleton {arch:{pacman,aur},darwin:{brew,cask},shared:{gh_extensions,go,cargo}}. Machinery before content.","status":"closed","priority":1,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-09T18:41:50Z","created_by":"Prad Nukala","updated_at":"2026-08-09T18:47:06Z","started_at":"2026-08-09T18:45:28Z","closed_at":"2026-08-09T18:47:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":13,"comment_count":0}
|
{"_type":"issue","id":"chezmoi-byc","title":"Gating skeleton: .chezmoi.toml.tmpl + .chezmoiignore.tmpl + packages.yaml skeleton","description":"promptOnce name/email/role (desktop|laptop|mac|server); chezmoiignore gates linux-only trees on darwin and vice versa; .chezmoidata/packages.yaml skeleton {arch:{pacman,aur},darwin:{brew,cask},shared:{gh_extensions,go,cargo}}. Machinery before content.","status":"closed","priority":1,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-09T18:41:50Z","created_by":"Prad Nukala","updated_at":"2026-08-09T18:47:06Z","started_at":"2026-08-09T18:45:28Z","closed_at":"2026-08-09T18:47:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":13,"comment_count":0}
|
||||||
{"_type":"issue","id":"chezmoi-rnv","title":"Inventory: disposition table for all dotstate synced paths","description":"Parse .dotstate-profiles.toml (Noctalia/Inir/Rogz/Vertical-Rogz_AppleStudio). Every synced path gets decision: universal / arch-gated / defer / drop / secret. Output table in MIGRATION-NOTES.md. Blocks all content.","status":"closed","priority":1,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-09T18:41:50Z","created_by":"Prad Nukala","updated_at":"2026-08-09T18:45:28Z","started_at":"2026-08-09T18:44:23Z","closed_at":"2026-08-09T18:45:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":13,"comment_count":0}
|
{"_type":"issue","id":"chezmoi-rnv","title":"Inventory: disposition table for all dotstate synced paths","description":"Parse .dotstate-profiles.toml (Noctalia/Inir/Rogz/Vertical-Rogz_AppleStudio). Every synced path gets decision: universal / arch-gated / defer / drop / secret. Output table in MIGRATION-NOTES.md. Blocks all content.","status":"closed","priority":1,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-09T18:41:50Z","created_by":"Prad Nukala","updated_at":"2026-08-09T18:45:28Z","started_at":"2026-08-09T18:44:23Z","closed_at":"2026-08-09T18:45:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":13,"comment_count":0}
|
||||||
|
{"_type":"issue","id":"chezmoi-288","title":"shell: fish functions -\u003e chezmoi executables + bash/zsh ports","status":"closed","priority":2,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-10T19:04:18Z","created_by":"Prad Nukala","updated_at":"2026-08-10T19:14:56Z","closed_at":"2026-08-10T19:14:56Z","close_reason":"16 cross-shell utilities -\u003e ~/.local/bin executables (ghg renamed ghg-clone: cargo-bin collision); cwd/history helpers ported as per-shell functions in bash/zsh function dirs; fish wrappers migrated to bins","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"_type":"issue","id":"chezmoi-hr0","title":"agents: manage ~/.agents so dot_claude skill symlinks resolve","status":"closed","priority":2,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-10T19:04:18Z","created_by":"Prad Nukala","updated_at":"2026-08-10T19:14:57Z","closed_at":"2026-08-10T19:14:57Z","close_reason":"dot_agents/skills materialized (82 skills, secret-scanned); live symlink cut over to real dir; dotstate profile+symlinks.json dropped .agents/.bashrc/.zshrc; storage repo committed+pushed; 0 broken ~/.claude/skills links","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"chezmoi-09g","title":"shell: starship + shared aliases for bash/zsh (synced with fish)","description":"aliases.yaml chezmoidata as single source; fish 20-aliases becomes tmpl; dot_config/shell/aliases.sh.tmpl for bash/zsh; managed dot_bashrc/dot_zshrc with starship init (drop flyline/p10k)","status":"closed","priority":2,"issue_type":"task","assignee":"Prad Nukala","owner":"prad@sonr.io","created_at":"2026-08-10T18:06:52Z","created_by":"Prad Nukala","updated_at":"2026-08-10T18:09:40Z","started_at":"2026-08-10T18:07:59Z","closed_at":"2026-08-10T18:09:40Z","close_reason":"bash/zsh managed with starship; aliases single-sourced in .chezmoidata/aliases.yaml, rendered to fish + shell/aliases.sh; verified in all three shells","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"chezmoi-09g","title":"shell: starship + shared aliases for bash/zsh (synced with fish)","description":"aliases.yaml chezmoidata as single source; fish 20-aliases becomes tmpl; dot_config/shell/aliases.sh.tmpl for bash/zsh; managed dot_bashrc/dot_zshrc with starship init (drop flyline/p10k)","status":"closed","priority":2,"issue_type":"task","assignee":"Prad Nukala","owner":"prad@sonr.io","created_at":"2026-08-10T18:06:52Z","created_by":"Prad Nukala","updated_at":"2026-08-10T18:09:40Z","started_at":"2026-08-10T18:07:59Z","closed_at":"2026-08-10T18:09:40Z","close_reason":"bash/zsh managed with starship; aliases single-sourced in .chezmoidata/aliases.yaml, rendered to fish + shell/aliases.sh; verified in all three shells","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"chezmoi-42m","title":"Validation: Arch fresh bring-up in container","description":"docker run archlinux: bootstrap one-liner, packages install, chezmoi doctor clean, niri validate, fish -n all files, nvim --headless +Lazy! sync, tmux -f cfg new -d; kill-server, gh extension list matches manifest. Plus live machine chezmoi diff ~ empty.","status":"in_progress","priority":2,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-09T18:42:17Z","created_by":"Prad Nukala","updated_at":"2026-08-09T18:58:33Z","started_at":"2026-08-09T18:58:33Z","dependencies":[{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-21v","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-4sj","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-9sg","type":"blocks","created_at":"2026-08-09T14:42:59Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-a2k","type":"blocks","created_at":"2026-08-09T14:42:59Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-exq","type":"blocks","created_at":"2026-08-09T14:42:59Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-fde","type":"blocks","created_at":"2026-08-09T14:42:59Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-hou","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-iu2","type":"blocks","created_at":"2026-08-09T14:42:59Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-sd9","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-utl","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-uw2","type":"blocks","created_at":"2026-08-09T14:42:57Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-x7f","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-xi5","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"}],"dependency_count":13,"dependent_count":1,"comment_count":0}
|
{"_type":"issue","id":"chezmoi-42m","title":"Validation: Arch fresh bring-up in container","description":"docker run archlinux: bootstrap one-liner, packages install, chezmoi doctor clean, niri validate, fish -n all files, nvim --headless +Lazy! sync, tmux -f cfg new -d; kill-server, gh extension list matches manifest. Plus live machine chezmoi diff ~ empty.","status":"in_progress","priority":2,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-09T18:42:17Z","created_by":"Prad Nukala","updated_at":"2026-08-09T18:58:33Z","started_at":"2026-08-09T18:58:33Z","dependencies":[{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-21v","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-4sj","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-9sg","type":"blocks","created_at":"2026-08-09T14:42:59Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-a2k","type":"blocks","created_at":"2026-08-09T14:42:59Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-exq","type":"blocks","created_at":"2026-08-09T14:42:59Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-fde","type":"blocks","created_at":"2026-08-09T14:42:59Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-hou","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-iu2","type":"blocks","created_at":"2026-08-09T14:42:59Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-sd9","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-utl","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-uw2","type":"blocks","created_at":"2026-08-09T14:42:57Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-x7f","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-42m","depends_on_id":"chezmoi-xi5","type":"blocks","created_at":"2026-08-09T14:42:58Z","created_by":"Prad Nukala","metadata":"{}"}],"dependency_count":13,"dependent_count":1,"comment_count":0}
|
||||||
{"_type":"issue","id":"chezmoi-5by","title":"Cutover: freeze dotstate for migrated paths","description":"After both validations: remove migrated paths from .dotstate-profiles.toml, tag dotstate pre-chezmoi-freeze, stop Dolt sync for those paths. Archive, never delete.","status":"open","priority":2,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-09T18:42:17Z","created_by":"Prad Nukala","updated_at":"2026-08-09T18:42:17Z","dependencies":[{"issue_id":"chezmoi-5by","depends_on_id":"chezmoi-42m","type":"blocks","created_at":"2026-08-09T14:42:25Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-5by","depends_on_id":"chezmoi-udp","type":"blocks","created_at":"2026-08-09T14:42:25Z","created_by":"Prad Nukala","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"chezmoi-5by","title":"Cutover: freeze dotstate for migrated paths","description":"After both validations: remove migrated paths from .dotstate-profiles.toml, tag dotstate pre-chezmoi-freeze, stop Dolt sync for those paths. Archive, never delete.","status":"open","priority":2,"issue_type":"task","owner":"prad@sonr.io","created_at":"2026-08-09T18:42:17Z","created_by":"Prad Nukala","updated_at":"2026-08-09T18:42:17Z","dependencies":[{"issue_id":"chezmoi-5by","depends_on_id":"chezmoi-42m","type":"blocks","created_at":"2026-08-09T14:42:25Z","created_by":"Prad Nukala","metadata":"{}"},{"issue_id":"chezmoi-5by","depends_on_id":"chezmoi-udp","type":"blocks","created_at":"2026-08-09T14:42:25Z","created_by":"Prad Nukala","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ AGENTS.md
|
|||||||
.config/systemd/**
|
.config/systemd/**
|
||||||
.local/bin/niri-launch
|
.local/bin/niri-launch
|
||||||
.config/fish/auto-Niri.fish
|
.config/fish/auto-Niri.fish
|
||||||
.config/fish/functions/cp-appid.fish
|
.local/bin/cp-appid
|
||||||
.config/tmux/niri-title.sh
|
.config/tmux/niri-title.sh
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ Aliases are defined once in `.chezmoidata/aliases.yaml` and rendered
|
|||||||
into fish (`fish/conf.d/20-aliases.fish`) and bash/zsh
|
into fish (`fish/conf.d/20-aliases.fish`) and bash/zsh
|
||||||
(`~/.config/shell/aliases.sh`, sourced by `.bashrc`/`.zshrc`).
|
(`~/.config/shell/aliases.sh`, sourced by `.bashrc`/`.zshrc`).
|
||||||
|
|
||||||
|
Shell utilities live as standalone sh/bash executables in
|
||||||
|
`~/.local/bin` (ai, c2p, fab, gdc, gho, ghn, ghg-clone, git-summary,
|
||||||
|
mdp, mdcp, pj, …) so all shells share one implementation. Helpers that
|
||||||
|
must mutate shell state (`cd`-style: yy, c, cd, ghg/ghn/gho wrappers,
|
||||||
|
clip-last) are per-shell functions in `fish/functions/`,
|
||||||
|
`bash/functions/`, and `zsh/functions/`.
|
||||||
|
|
||||||
|
`~/.agents/skills` (agent skill library) is managed directly; the
|
||||||
|
symlinks under `~/.claude/skills` resolve against it.
|
||||||
|
|
||||||
Linux desktop (role `desktop`/`laptop` only): niri, noctalia,
|
Linux desktop (role `desktop`/`laptop` only): niri, noctalia,
|
||||||
fontconfig, mimeapps, systemd user units, niri-launch.
|
fontconfig, mimeapps, systemd user units, niri-launch.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,796 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"skills": {
|
||||||
|
"mintlify": {
|
||||||
|
"source": "mintlify.com",
|
||||||
|
"sourceType": "well-known",
|
||||||
|
"sourceUrl": "https://mintlify.com/docs/.well-known/agent-skills/mintlify/skill.md",
|
||||||
|
"skillFolderHash": "",
|
||||||
|
"installedAt": "2026-03-06T23:16:24.133Z",
|
||||||
|
"updatedAt": "2026-07-09T16:33:01.435Z"
|
||||||
|
},
|
||||||
|
"find-skills": {
|
||||||
|
"source": "vercel-labs/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/skills.git",
|
||||||
|
"skillPath": "skills/find-skills/SKILL.md",
|
||||||
|
"skillFolderHash": "76a98a285cb0434f3d39e1a873823556330e398b",
|
||||||
|
"installedAt": "2026-03-06T23:16:26.850Z",
|
||||||
|
"updatedAt": "2026-07-11T22:58:43.931Z"
|
||||||
|
},
|
||||||
|
"web-design-guidelines": {
|
||||||
|
"source": "vercel-labs/agent-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/agent-skills.git",
|
||||||
|
"skillPath": "skills/web-design-guidelines/SKILL.md",
|
||||||
|
"skillFolderHash": "3116f3e62dbd02b44a598b1aa690d2a8938e8f89",
|
||||||
|
"installedAt": "2026-03-06T23:17:28.357Z",
|
||||||
|
"updatedAt": "2026-03-06T23:17:28.357Z"
|
||||||
|
},
|
||||||
|
"wrangler": {
|
||||||
|
"source": "cloudflare/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/cloudflare/skills.git",
|
||||||
|
"skillPath": "skills/wrangler/SKILL.md",
|
||||||
|
"skillFolderHash": "45cc198b2aad3f06e8abf91333f55fbe7579f659",
|
||||||
|
"installedAt": "2026-03-06T23:17:51.886Z",
|
||||||
|
"updatedAt": "2026-05-14T13:17:51.800Z"
|
||||||
|
},
|
||||||
|
"tanstack-start-best-practices": {
|
||||||
|
"source": "deckardger/tanstack-agent-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/deckardger/tanstack-agent-skills.git",
|
||||||
|
"skillPath": "skills/tanstack-start/SKILL.md",
|
||||||
|
"skillFolderHash": "f51cf71300531adc6e9af58e2a3a578be8a58aea",
|
||||||
|
"installedAt": "2026-03-06T23:18:14.260Z",
|
||||||
|
"updatedAt": "2026-05-14T13:17:52.807Z"
|
||||||
|
},
|
||||||
|
"tanstack-query-best-practices": {
|
||||||
|
"source": "deckardger/tanstack-agent-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/deckardger/tanstack-agent-skills.git",
|
||||||
|
"skillPath": "skills/tanstack-query/SKILL.md",
|
||||||
|
"skillFolderHash": "b73085898a2015a0f33c98aef0f3f2ff4ee9454f",
|
||||||
|
"installedAt": "2026-03-06T23:18:29.570Z",
|
||||||
|
"updatedAt": "2026-03-06T23:18:29.570Z"
|
||||||
|
},
|
||||||
|
"ethereum-wingman": {
|
||||||
|
"source": "austintgriffith/ethereum-wingman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/austintgriffith/ethereum-wingman.git",
|
||||||
|
"skillPath": "skills/ethereum-wingman/SKILL.md",
|
||||||
|
"skillFolderHash": "87c6e8d93ea71cf608ef13b3c27fae6f2b5466a4",
|
||||||
|
"installedAt": "2026-03-06T23:19:25.834Z",
|
||||||
|
"updatedAt": "2026-03-06T23:19:25.834Z"
|
||||||
|
},
|
||||||
|
"solidity-security": {
|
||||||
|
"source": "wshobson/agents",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/wshobson/agents.git",
|
||||||
|
"skillPath": "plugins/blockchain-web3/skills/solidity-security/SKILL.md",
|
||||||
|
"skillFolderHash": "04eced95a2501b780e71b1157b928de1ad34c538",
|
||||||
|
"installedAt": "2026-03-06T23:19:43.396Z",
|
||||||
|
"updatedAt": "2026-05-28T16:55:40.447Z"
|
||||||
|
},
|
||||||
|
"solidity-foundry-development": {
|
||||||
|
"source": "whackur/solidity-agent-toolkit",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/whackur/solidity-agent-toolkit.git",
|
||||||
|
"skillPath": "skills/solidity-foundry-development/SKILL.md",
|
||||||
|
"skillFolderHash": "195d13ac69743f20cd1b845d9f6715263626116e",
|
||||||
|
"installedAt": "2026-03-06T23:20:07.263Z",
|
||||||
|
"updatedAt": "2026-06-28T19:36:33.256Z"
|
||||||
|
},
|
||||||
|
"golang-pro": {
|
||||||
|
"source": "jeffallan/claude-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/jeffallan/claude-skills.git",
|
||||||
|
"skillPath": "skills/golang-pro/SKILL.md",
|
||||||
|
"skillFolderHash": "e347d9842958ce46d5f0644ef6009304d648a964",
|
||||||
|
"installedAt": "2026-03-06T23:21:16.634Z",
|
||||||
|
"updatedAt": "2026-05-14T13:17:56.339Z"
|
||||||
|
},
|
||||||
|
"Technical Writer": {
|
||||||
|
"source": "daffy0208/ai-dev-standards",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/daffy0208/ai-dev-standards.git",
|
||||||
|
"skillPath": "skills/technical-writer/SKILL.md",
|
||||||
|
"skillFolderHash": "0491075584fae0727df54f24e0e5cb6344b165bf",
|
||||||
|
"installedAt": "2026-03-06T23:21:33.091Z",
|
||||||
|
"updatedAt": "2026-03-06T23:21:33.091Z"
|
||||||
|
},
|
||||||
|
"uv-package-manager": {
|
||||||
|
"source": "wshobson/agents",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/wshobson/agents.git",
|
||||||
|
"skillPath": "plugins/python-development/skills/uv-package-manager/SKILL.md",
|
||||||
|
"skillFolderHash": "f80b5182adb7bec902841316352bb9e1b8871fbf",
|
||||||
|
"installedAt": "2026-03-08T01:09:07.357Z",
|
||||||
|
"updatedAt": "2026-05-14T13:17:57.333Z"
|
||||||
|
},
|
||||||
|
"bun-development": {
|
||||||
|
"source": "sickn33/antigravity-awesome-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/sickn33/antigravity-awesome-skills.git",
|
||||||
|
"skillPath": "skills/bun-development/SKILL.md",
|
||||||
|
"skillFolderHash": "309d79a55351d0cf21b64cd908be064ab5df497d",
|
||||||
|
"installedAt": "2026-03-08T01:09:35.482Z",
|
||||||
|
"updatedAt": "2026-07-05T21:43:22.418Z"
|
||||||
|
},
|
||||||
|
"core": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/core/SKILL.md",
|
||||||
|
"skillFolderHash": "05d62717178814848f7ea80e5d8a022132a6a85d",
|
||||||
|
"installedAt": "2026-03-13T21:55:31.291Z",
|
||||||
|
"updatedAt": "2026-06-16T19:27:58.864Z"
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/react/SKILL.md",
|
||||||
|
"skillFolderHash": "e464186113a0ba176c602d74164041e242113854",
|
||||||
|
"installedAt": "2026-03-13T21:55:40.915Z",
|
||||||
|
"updatedAt": "2026-06-16T19:27:59.795Z"
|
||||||
|
},
|
||||||
|
"react-pdf": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/react-pdf/SKILL.md",
|
||||||
|
"skillFolderHash": "d1fa1d79a29cdc3692c7baacee582cbbf0fa0cc1",
|
||||||
|
"installedAt": "2026-03-13T21:55:50.545Z",
|
||||||
|
"updatedAt": "2026-03-13T21:55:50.545Z"
|
||||||
|
},
|
||||||
|
"react-email": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/react-email/SKILL.md",
|
||||||
|
"skillFolderHash": "359d1a515186b7aed175d990e60f2d4fd3ea414d",
|
||||||
|
"installedAt": "2026-03-13T21:55:57.139Z",
|
||||||
|
"updatedAt": "2026-03-13T21:55:57.139Z"
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/react-native/SKILL.md",
|
||||||
|
"skillFolderHash": "5428d72027a35d709a68ea10400cb7923f397140",
|
||||||
|
"installedAt": "2026-03-13T21:56:04.694Z",
|
||||||
|
"updatedAt": "2026-03-13T21:56:04.694Z"
|
||||||
|
},
|
||||||
|
"shadcn": {
|
||||||
|
"source": "shadcn/ui",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/shadcn/ui.git",
|
||||||
|
"skillPath": "skills/shadcn/SKILL.md",
|
||||||
|
"skillFolderHash": "962eb7ef77f17e0bce530153d4fec2136c5fa061",
|
||||||
|
"installedAt": "2026-03-13T21:56:09.517Z",
|
||||||
|
"updatedAt": "2026-07-05T21:43:24.792Z"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/image/SKILL.md",
|
||||||
|
"skillFolderHash": "f697c8a50da72a9ca2f1cc37c74f87a7a90b6722",
|
||||||
|
"installedAt": "2026-03-13T21:56:14.738Z",
|
||||||
|
"updatedAt": "2026-03-13T21:56:14.738Z"
|
||||||
|
},
|
||||||
|
"remotion": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/remotion/SKILL.md",
|
||||||
|
"skillFolderHash": "a1a047a21d40a39e8b13853a326fb1e53b967706",
|
||||||
|
"installedAt": "2026-03-13T21:58:08.516Z",
|
||||||
|
"updatedAt": "2026-03-13T21:58:08.516Z"
|
||||||
|
},
|
||||||
|
"vue": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/vue/SKILL.md",
|
||||||
|
"skillFolderHash": "478436a2ed1034ddd8db79c9bd74c88e37ef0696",
|
||||||
|
"installedAt": "2026-03-13T21:58:14.274Z",
|
||||||
|
"updatedAt": "2026-03-13T21:58:14.274Z"
|
||||||
|
},
|
||||||
|
"svelte": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/svelte/SKILL.md",
|
||||||
|
"skillFolderHash": "7cade2dfb70c837ec40acdf3cca64afcef278bed",
|
||||||
|
"installedAt": "2026-03-13T21:58:19.264Z",
|
||||||
|
"updatedAt": "2026-03-13T21:58:19.264Z"
|
||||||
|
},
|
||||||
|
"solid": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/solid/SKILL.md",
|
||||||
|
"skillFolderHash": "61023accb0990e70f235a6a89ba28a7d373a9b45",
|
||||||
|
"installedAt": "2026-03-13T21:58:23.919Z",
|
||||||
|
"updatedAt": "2026-03-13T21:58:23.919Z"
|
||||||
|
},
|
||||||
|
"codegen": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/codegen/SKILL.md",
|
||||||
|
"skillFolderHash": "5bd34abdd5045973433399f14e96559dce28c0b4",
|
||||||
|
"installedAt": "2026-03-13T21:58:28.992Z",
|
||||||
|
"updatedAt": "2026-03-13T21:58:28.992Z"
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/mcp/SKILL.md",
|
||||||
|
"skillFolderHash": "eb7a93c6df1ecf6997c3fe6ea2c92847cfbe3114",
|
||||||
|
"installedAt": "2026-03-13T21:58:34.168Z",
|
||||||
|
"updatedAt": "2026-03-13T21:58:34.168Z"
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/redux/SKILL.md",
|
||||||
|
"skillFolderHash": "b67e39ae74ba8c338657fe420b95c60a06cf6270",
|
||||||
|
"installedAt": "2026-03-13T21:58:39.965Z",
|
||||||
|
"updatedAt": "2026-03-13T21:58:39.965Z"
|
||||||
|
},
|
||||||
|
"zustand": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/zustand/SKILL.md",
|
||||||
|
"skillFolderHash": "4315759f61175e4b75c45bab1e72caccbd440c42",
|
||||||
|
"installedAt": "2026-03-13T21:58:45.208Z",
|
||||||
|
"updatedAt": "2026-03-13T21:58:45.208Z"
|
||||||
|
},
|
||||||
|
"jotai": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/jotai/SKILL.md",
|
||||||
|
"skillFolderHash": "60b5a6ebcc751d25bae4faa2c9be7f36085cb65f",
|
||||||
|
"installedAt": "2026-03-13T21:58:50.970Z",
|
||||||
|
"updatedAt": "2026-03-13T21:58:50.970Z"
|
||||||
|
},
|
||||||
|
"xstate": {
|
||||||
|
"source": "vercel-labs/json-render",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/json-render.git",
|
||||||
|
"skillPath": "skills/xstate/SKILL.md",
|
||||||
|
"skillFolderHash": "58cc03a9b085c2ec82fdffdfb388893b7d59fa19",
|
||||||
|
"installedAt": "2026-03-13T21:58:59.740Z",
|
||||||
|
"updatedAt": "2026-03-13T21:58:59.740Z"
|
||||||
|
},
|
||||||
|
"grill-me": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/productivity/grill-me/SKILL.md",
|
||||||
|
"skillFolderHash": "8320e7b87f7b208f50ce165b1dd43d1e93c8e801",
|
||||||
|
"pluginName": "mattpocock-skills",
|
||||||
|
"installedAt": "2026-05-11T17:38:14.403Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.604Z"
|
||||||
|
},
|
||||||
|
"grill-with-docs": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/engineering/grill-with-docs/SKILL.md",
|
||||||
|
"skillFolderHash": "21adba95de7d93065d9fec725ddf37f871e01d05",
|
||||||
|
"pluginName": "mattpocock-skills",
|
||||||
|
"installedAt": "2026-05-11T17:38:14.403Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.604Z"
|
||||||
|
},
|
||||||
|
"handoff": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/productivity/handoff/SKILL.md",
|
||||||
|
"skillFolderHash": "3f7df8e717e41dfd6eacd03d47319a2e01248ffb",
|
||||||
|
"installedAt": "2026-05-11T17:38:14.404Z",
|
||||||
|
"updatedAt": "2026-07-09T16:32:30.968Z"
|
||||||
|
},
|
||||||
|
"improve-codebase-architecture": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md",
|
||||||
|
"skillFolderHash": "e5beae48471adf87c513b5cbcb0ca347467546da",
|
||||||
|
"pluginName": "mattpocock-skills",
|
||||||
|
"installedAt": "2026-05-11T17:38:14.404Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.606Z"
|
||||||
|
},
|
||||||
|
"prototype": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/engineering/prototype/SKILL.md",
|
||||||
|
"skillFolderHash": "e50d76191bf197e3d947eb8c780c7009269ba589",
|
||||||
|
"installedAt": "2026-05-11T17:38:14.405Z",
|
||||||
|
"updatedAt": "2026-07-11T22:58:44.875Z"
|
||||||
|
},
|
||||||
|
"setup-matt-pocock-skills": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md",
|
||||||
|
"skillFolderHash": "ed1bbe6a06bb049aaa95bfe2c487bdb7a8300838",
|
||||||
|
"installedAt": "2026-05-11T17:38:14.405Z",
|
||||||
|
"updatedAt": "2026-07-11T22:58:45.876Z"
|
||||||
|
},
|
||||||
|
"tdd": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/engineering/tdd/SKILL.md",
|
||||||
|
"skillFolderHash": "57e1bee887816ed2af745e5fd388f224f6e61ada",
|
||||||
|
"installedAt": "2026-05-11T17:38:14.405Z",
|
||||||
|
"updatedAt": "2026-07-05T21:43:26.608Z"
|
||||||
|
},
|
||||||
|
"triage": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/engineering/triage/SKILL.md",
|
||||||
|
"skillFolderHash": "3e7a49151929d884365035cb322de099c15b33d3",
|
||||||
|
"pluginName": "mattpocock-skills",
|
||||||
|
"installedAt": "2026-05-11T17:38:14.406Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.607Z"
|
||||||
|
},
|
||||||
|
"remix": {
|
||||||
|
"source": "remix-run/remix",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/remix-run/remix.git",
|
||||||
|
"skillPath": ".agents/skills/remix/SKILL.md",
|
||||||
|
"skillFolderHash": "3b50ba213a7ca5cb296dd34b3a4e88ede17b30e7",
|
||||||
|
"installedAt": "2026-05-23T19:11:24.642Z",
|
||||||
|
"updatedAt": "2026-06-28T19:36:34.960Z"
|
||||||
|
},
|
||||||
|
"typescript-expert": {
|
||||||
|
"source": "remix-run/remix",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/remix-run/remix.git",
|
||||||
|
"skillPath": ".agents/skills/typescript-expert/SKILL.md",
|
||||||
|
"skillFolderHash": "e6926fb96faf26e3c46900aab1135b202436719e",
|
||||||
|
"installedAt": "2026-05-23T19:11:24.644Z",
|
||||||
|
"updatedAt": "2026-05-23T19:11:24.644Z"
|
||||||
|
},
|
||||||
|
"write-tests": {
|
||||||
|
"source": "remix-run/remix",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/remix-run/remix.git",
|
||||||
|
"skillPath": ".agents/skills/write-tests/SKILL.md",
|
||||||
|
"skillFolderHash": "4b4e2847a6c86c53418b2f2303c4d40187a1d1a4",
|
||||||
|
"installedAt": "2026-05-23T19:11:24.644Z",
|
||||||
|
"updatedAt": "2026-05-23T19:11:24.644Z"
|
||||||
|
},
|
||||||
|
"design-taste-frontend": {
|
||||||
|
"source": "Leonxlnx/taste-skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/Leonxlnx/taste-skill.git",
|
||||||
|
"skillPath": "skills/taste-skill/SKILL.md",
|
||||||
|
"skillFolderHash": "a6d128e53b4ec0238baee751dde33bf707adb5ec",
|
||||||
|
"installedAt": "2026-05-26T16:59:11.011Z",
|
||||||
|
"updatedAt": "2026-05-28T16:55:47.754Z"
|
||||||
|
},
|
||||||
|
"gpt-taste": {
|
||||||
|
"source": "Leonxlnx/taste-skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/Leonxlnx/taste-skill.git",
|
||||||
|
"skillPath": "skills/gpt-tasteskill/SKILL.md",
|
||||||
|
"skillFolderHash": "c87d529ad945cd35985b19475bf53f41682d4f69",
|
||||||
|
"installedAt": "2026-05-26T16:59:11.012Z",
|
||||||
|
"updatedAt": "2026-05-26T16:59:11.012Z"
|
||||||
|
},
|
||||||
|
"high-end-visual-design": {
|
||||||
|
"source": "Leonxlnx/taste-skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/Leonxlnx/taste-skill.git",
|
||||||
|
"skillPath": "skills/soft-skill/SKILL.md",
|
||||||
|
"skillFolderHash": "1b5943432ddd1b980206968c529a323cc992dbec",
|
||||||
|
"installedAt": "2026-05-26T16:59:11.014Z",
|
||||||
|
"updatedAt": "2026-05-26T16:59:11.014Z"
|
||||||
|
},
|
||||||
|
"imagegen-frontend-web": {
|
||||||
|
"source": "Leonxlnx/taste-skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/Leonxlnx/taste-skill.git",
|
||||||
|
"skillPath": "skills/imagegen-frontend-web/SKILL.md",
|
||||||
|
"skillFolderHash": "3b5117d349200e33f9c3399344681620079dcc18",
|
||||||
|
"installedAt": "2026-05-26T16:59:11.014Z",
|
||||||
|
"updatedAt": "2026-05-26T16:59:11.014Z"
|
||||||
|
},
|
||||||
|
"minimalist-ui": {
|
||||||
|
"source": "Leonxlnx/taste-skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/Leonxlnx/taste-skill.git",
|
||||||
|
"skillPath": "skills/minimalist-skill/SKILL.md",
|
||||||
|
"skillFolderHash": "5509c012862cda27e2ed7efae89ed940ecc53add",
|
||||||
|
"installedAt": "2026-05-26T16:59:11.015Z",
|
||||||
|
"updatedAt": "2026-05-26T16:59:11.015Z"
|
||||||
|
},
|
||||||
|
"redesign-existing-projects": {
|
||||||
|
"source": "Leonxlnx/taste-skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/Leonxlnx/taste-skill.git",
|
||||||
|
"skillPath": "skills/redesign-skill/SKILL.md",
|
||||||
|
"skillFolderHash": "d9cffe56d26d4af7687299e391a7be1cfaf46c1b",
|
||||||
|
"installedAt": "2026-05-26T16:59:11.015Z",
|
||||||
|
"updatedAt": "2026-05-26T16:59:11.015Z"
|
||||||
|
},
|
||||||
|
"brandkit": {
|
||||||
|
"source": "Leonxlnx/taste-skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/Leonxlnx/taste-skill.git",
|
||||||
|
"skillPath": "skills/brandkit/SKILL.md",
|
||||||
|
"skillFolderHash": "d278ff748cafab01e4ae5dd7758fc0b53258621c",
|
||||||
|
"installedAt": "2026-05-26T16:59:11.015Z",
|
||||||
|
"updatedAt": "2026-05-26T16:59:11.015Z"
|
||||||
|
},
|
||||||
|
"stop-slop": {
|
||||||
|
"source": "hardikpandya/stop-slop",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/hardikpandya/stop-slop.git",
|
||||||
|
"skillPath": "SKILL.md",
|
||||||
|
"skillFolderHash": "8da1f030185bdfe8471220585162991eaeb970e9",
|
||||||
|
"installedAt": "2026-05-26T16:59:31.618Z",
|
||||||
|
"updatedAt": "2026-05-26T16:59:31.618Z"
|
||||||
|
},
|
||||||
|
"openlogs-server-logs": {
|
||||||
|
"source": "charlietlamb/openlogs",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/charlietlamb/openlogs.git",
|
||||||
|
"skillPath": "skills/openlogs-server-logs/SKILL.md",
|
||||||
|
"skillFolderHash": "42fb220fe9596d6a30679940ecdf44082a166954",
|
||||||
|
"installedAt": "2026-06-10T21:12:16.667Z",
|
||||||
|
"updatedAt": "2026-06-10T21:12:16.667Z"
|
||||||
|
},
|
||||||
|
"linear": {
|
||||||
|
"source": "openai/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/openai/skills.git",
|
||||||
|
"skillPath": "skills/.curated/linear/SKILL.md",
|
||||||
|
"skillFolderHash": "31c1648f2d1a4d05d32eab4bdfb2ed89a5541e06",
|
||||||
|
"installedAt": "2026-06-23T19:50:00.950Z",
|
||||||
|
"updatedAt": "2026-06-23T19:50:00.950Z"
|
||||||
|
},
|
||||||
|
"linear-cli": {
|
||||||
|
"source": "schpet/linear-cli",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/schpet/linear-cli.git",
|
||||||
|
"skillPath": "skills/linear-cli/SKILL.md",
|
||||||
|
"skillFolderHash": "5db263378a03ca0d733e3b55695bffab4ccc7896",
|
||||||
|
"installedAt": "2026-06-23T19:51:54.496Z",
|
||||||
|
"updatedAt": "2026-07-11T22:58:46.750Z"
|
||||||
|
},
|
||||||
|
"linear-documents": {
|
||||||
|
"source": "finesssee/linear-cli",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/finesssee/linear-cli.git",
|
||||||
|
"skillPath": "skills/linear-documents/SKILL.md",
|
||||||
|
"skillFolderHash": "4c306d0268f42978363078262380fd63912a7493",
|
||||||
|
"installedAt": "2026-06-23T19:53:25.148Z",
|
||||||
|
"updatedAt": "2026-06-23T19:53:25.148Z"
|
||||||
|
},
|
||||||
|
"codebase-design": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/engineering/codebase-design/SKILL.md",
|
||||||
|
"skillFolderHash": "c5dfc023e5ae146a4ceb9c509024f6ffe13db4a3",
|
||||||
|
"pluginName": "mattpocock-skills",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.602Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.602Z"
|
||||||
|
},
|
||||||
|
"diagnosing-bugs": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/engineering/diagnosing-bugs/SKILL.md",
|
||||||
|
"skillFolderHash": "0ecacff09e31245c34727abe7cb7cff71f615a35",
|
||||||
|
"pluginName": "mattpocock-skills",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.603Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.603Z"
|
||||||
|
},
|
||||||
|
"domain-modeling": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/engineering/domain-modeling/SKILL.md",
|
||||||
|
"skillFolderHash": "028a0e44b23909cbdd715aa00546d5c1c46ae4d6",
|
||||||
|
"pluginName": "mattpocock-skills",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.604Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.604Z"
|
||||||
|
},
|
||||||
|
"grilling": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/productivity/grilling/SKILL.md",
|
||||||
|
"skillFolderHash": "0ace40ac10f5c0b1534324e4e109ebfa831fb361",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.605Z",
|
||||||
|
"updatedAt": "2026-07-09T16:32:32.121Z"
|
||||||
|
},
|
||||||
|
"teach": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/productivity/teach/SKILL.md",
|
||||||
|
"skillFolderHash": "3b6a4245e2aaeacb308ba6175ab3e7f2e43b0c47",
|
||||||
|
"pluginName": "mattpocock-skills",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.607Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.607Z"
|
||||||
|
},
|
||||||
|
"writing-great-skills": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/productivity/writing-great-skills/SKILL.md",
|
||||||
|
"skillFolderHash": "c84411b360fad91b8756e60fa17fd5b5761422dd",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.608Z",
|
||||||
|
"updatedAt": "2026-07-09T16:32:32.702Z"
|
||||||
|
},
|
||||||
|
"design-an-interface": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/deprecated/design-an-interface/SKILL.md",
|
||||||
|
"skillFolderHash": "59d5eca0fd23930d3d3821aa9da507f1bdb8a348",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.608Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.608Z"
|
||||||
|
},
|
||||||
|
"git-guardrails-claude-code": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/misc/git-guardrails-claude-code/SKILL.md",
|
||||||
|
"skillFolderHash": "f02ae7c2de75270e93a200e11a7e466ece897353",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.609Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.609Z"
|
||||||
|
},
|
||||||
|
"implement": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/engineering/implement/SKILL.md",
|
||||||
|
"skillFolderHash": "aa65990650368f020166bc28d6c05b5b34592c97",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.609Z",
|
||||||
|
"updatedAt": "2026-07-09T16:32:33.264Z"
|
||||||
|
},
|
||||||
|
"loop-me": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/in-progress/loop-me/SKILL.md",
|
||||||
|
"skillFolderHash": "1cec9deaf4f4ce1b99bd0750f1e34bc1100b1285",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.609Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.609Z"
|
||||||
|
},
|
||||||
|
"migrate-to-shoehorn": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/misc/migrate-to-shoehorn/SKILL.md",
|
||||||
|
"skillFolderHash": "ad722b79beb1c1fb998678d498ba11760d53714b",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.609Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.609Z"
|
||||||
|
},
|
||||||
|
"obsidian-vault": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/personal/obsidian-vault/SKILL.md",
|
||||||
|
"skillFolderHash": "1e814d168a215762391895aee58f4de06c8e3b7f",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.609Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.609Z"
|
||||||
|
},
|
||||||
|
"qa": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/deprecated/qa/SKILL.md",
|
||||||
|
"skillFolderHash": "3eed5beeb18e77776349782ed087ee4560fd69c5",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.610Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.610Z"
|
||||||
|
},
|
||||||
|
"request-refactor-plan": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/deprecated/request-refactor-plan/SKILL.md",
|
||||||
|
"skillFolderHash": "b0617c9cf69ac1e73a6b30f78c82ff81372079fd",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.610Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.610Z"
|
||||||
|
},
|
||||||
|
"resolving-merge-conflicts": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/engineering/resolving-merge-conflicts/SKILL.md",
|
||||||
|
"skillFolderHash": "b601c90446f887a94ec68e5c2301a8919f949a2c",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.610Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.610Z"
|
||||||
|
},
|
||||||
|
"scaffold-exercises": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/misc/scaffold-exercises/SKILL.md",
|
||||||
|
"skillFolderHash": "ef19ca90ec6645b23dc7e68601552ab795e3e6ee",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.611Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.611Z"
|
||||||
|
},
|
||||||
|
"setup-pre-commit": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/misc/setup-pre-commit/SKILL.md",
|
||||||
|
"skillFolderHash": "5e6e9301b4826bd820401ea4114a797adb169f47",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.611Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.611Z"
|
||||||
|
},
|
||||||
|
"ubiquitous-language": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/deprecated/ubiquitous-language/SKILL.md",
|
||||||
|
"skillFolderHash": "483fe10b01bed81b7eba7ee26df3f1bef9540c88",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.611Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.611Z"
|
||||||
|
},
|
||||||
|
"writing-beats": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/in-progress/writing-beats/SKILL.md",
|
||||||
|
"skillFolderHash": "28c4a84a44e1e44b28ce0c45e01892fa3691ace6",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.612Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.612Z"
|
||||||
|
},
|
||||||
|
"writing-fragments": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/in-progress/writing-fragments/SKILL.md",
|
||||||
|
"skillFolderHash": "ae1380aef2436f7a7eb1d62aedcc5f2b8e71d6c5",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.612Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.612Z"
|
||||||
|
},
|
||||||
|
"writing-shape": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/mattpocock/skills.git",
|
||||||
|
"skillPath": "skills/in-progress/writing-shape/SKILL.md",
|
||||||
|
"skillFolderHash": "2ca73419c5762c668a30ae0c160165b9baf5b133",
|
||||||
|
"installedAt": "2026-06-27T19:37:25.613Z",
|
||||||
|
"updatedAt": "2026-06-27T19:37:25.613Z"
|
||||||
|
},
|
||||||
|
"loopy": {
|
||||||
|
"source": "Forward-Future/loop-library",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/Forward-Future/loop-library.git",
|
||||||
|
"skillPath": "skills/loopy/SKILL.md",
|
||||||
|
"skillFolderHash": "29f8d21b72ea1326ceac048b362ff344ecb5fcab",
|
||||||
|
"installedAt": "2026-06-29T15:24:59.672Z",
|
||||||
|
"updatedAt": "2026-07-05T21:43:28.623Z"
|
||||||
|
},
|
||||||
|
"deepinit": {
|
||||||
|
"source": "yeachan-heo/oh-my-claudecode",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/yeachan-heo/oh-my-claudecode.git",
|
||||||
|
"skillPath": "skills/deepinit/SKILL.md",
|
||||||
|
"skillFolderHash": "5839f9be75783f998424c936ce3b62d5a443718f",
|
||||||
|
"pluginName": "oh-my-claudecode",
|
||||||
|
"installedAt": "2026-06-30T14:03:37.084Z",
|
||||||
|
"updatedAt": "2026-06-30T14:03:37.084Z"
|
||||||
|
},
|
||||||
|
"native-sdk": {
|
||||||
|
"source": "vercel-labs/native",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/vercel-labs/native.git",
|
||||||
|
"skillPath": "skills/native-sdk/SKILL.md",
|
||||||
|
"skillFolderHash": "72221bd76c44638d2db54c7cc0656083c61c5258",
|
||||||
|
"installedAt": "2026-07-09T15:09:31.311Z",
|
||||||
|
"updatedAt": "2026-07-11T22:58:47.416Z"
|
||||||
|
},
|
||||||
|
"mintlify-api": {
|
||||||
|
"source": "mintlify.com",
|
||||||
|
"sourceType": "well-known",
|
||||||
|
"sourceUrl": "https://mintlify.com/docs/.well-known/agent-skills/mintlify-api/skill.md",
|
||||||
|
"skillFolderHash": "",
|
||||||
|
"installedAt": "2026-07-09T16:33:01.435Z",
|
||||||
|
"updatedAt": "2026-07-09T16:33:01.435Z"
|
||||||
|
},
|
||||||
|
"mintlify-docs": {
|
||||||
|
"source": "mintlify.com",
|
||||||
|
"sourceType": "well-known",
|
||||||
|
"sourceUrl": "https://mintlify.com/docs/.well-known/agent-skills/mintlify-docs/skill.md",
|
||||||
|
"skillFolderHash": "",
|
||||||
|
"installedAt": "2026-07-09T16:33:01.437Z",
|
||||||
|
"updatedAt": "2026-07-09T16:33:01.437Z"
|
||||||
|
},
|
||||||
|
"blume": {
|
||||||
|
"source": "haydenbleasel/blume",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/haydenbleasel/blume.git",
|
||||||
|
"skillPath": "skills/blume/SKILL.md",
|
||||||
|
"skillFolderHash": "e1c680e3304ee45fa160c421ecb73137d6bc1843",
|
||||||
|
"installedAt": "2026-07-14T15:07:06.129Z",
|
||||||
|
"updatedAt": "2026-07-21T17:52:06.810Z"
|
||||||
|
},
|
||||||
|
"blume-update-docs": {
|
||||||
|
"source": "haydenbleasel/blume",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/haydenbleasel/blume.git",
|
||||||
|
"skillPath": "skills/blume-update-docs/SKILL.md",
|
||||||
|
"skillFolderHash": "cd89e3a3cecd361d1cf4debab3c1cd403b0833ed",
|
||||||
|
"installedAt": "2026-07-14T15:07:06.131Z",
|
||||||
|
"updatedAt": "2026-07-21T17:52:06.814Z"
|
||||||
|
},
|
||||||
|
"blume-migrate": {
|
||||||
|
"source": "haydenbleasel/blume",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/haydenbleasel/blume.git",
|
||||||
|
"skillPath": "skills/blume-migrate/SKILL.md",
|
||||||
|
"skillFolderHash": "b902d68a527f3650b3ccffe9477bc087107cd6e7",
|
||||||
|
"installedAt": "2026-07-21T17:52:06.811Z",
|
||||||
|
"updatedAt": "2026-07-21T17:52:06.811Z"
|
||||||
|
},
|
||||||
|
"rust-best-practices": {
|
||||||
|
"source": "apollographql/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/apollographql/skills.git",
|
||||||
|
"skillPath": "skills/rust-best-practices/SKILL.md",
|
||||||
|
"skillFolderHash": "73bee942cb621e4ee7f2c3f86de01ab09638ee5d",
|
||||||
|
"installedAt": "2026-07-21T18:52:13.087Z",
|
||||||
|
"updatedAt": "2026-07-21T18:52:13.087Z"
|
||||||
|
},
|
||||||
|
"rust-async-patterns": {
|
||||||
|
"source": "wshobson/agents",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/wshobson/agents.git",
|
||||||
|
"skillPath": "plugins/systems-programming/skills/rust-async-patterns/SKILL.md",
|
||||||
|
"skillFolderHash": "3624037dbe3d96e2e55ecb6988afeaffbbb9ad1b",
|
||||||
|
"installedAt": "2026-07-21T18:52:26.413Z",
|
||||||
|
"updatedAt": "2026-07-21T18:52:26.413Z"
|
||||||
|
},
|
||||||
|
"rust-testing": {
|
||||||
|
"source": "affaan-m/everything-claude-code",
|
||||||
|
"sourceType": "github",
|
||||||
|
"sourceUrl": "https://github.com/affaan-m/everything-claude-code.git",
|
||||||
|
"skillPath": "skills/rust-testing/SKILL.md",
|
||||||
|
"skillFolderHash": "38d383ad33a4ea55498b6a35ec40f33be5e094ab",
|
||||||
|
"installedAt": "2026-07-21T18:52:43.658Z",
|
||||||
|
"updatedAt": "2026-07-21T18:52:43.658Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dismissed": {
|
||||||
|
"findSkillsPrompt": true
|
||||||
|
},
|
||||||
|
"lastSelectedAgents": [
|
||||||
|
"amp",
|
||||||
|
"antigravity",
|
||||||
|
"antigravity-cli",
|
||||||
|
"cline",
|
||||||
|
"codex",
|
||||||
|
"cursor",
|
||||||
|
"deepagents",
|
||||||
|
"gemini-cli",
|
||||||
|
"github-copilot",
|
||||||
|
"kimi-code-cli",
|
||||||
|
"opencode",
|
||||||
|
"warp",
|
||||||
|
"zed",
|
||||||
|
"claude-code",
|
||||||
|
"pi"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
name: blume-migrate
|
||||||
|
description: Migrate an existing documentation site (Mintlify, Docusaurus, Fumadocs, Nextra, Starlight, or any docs framework) to Blume, the markdown-first docs framework on Astro. Translate the source config to blume.config.ts, restructure content into Blume's filesystem-derived navigation, rewrite JSX callouts to directives, convert icons to Lucide, and inline snippets. Use when the user asks to migrate/convert/port a docs repo to Blume, or when the repo has a docs.json/mint.json, docusaurus.config.*, meta.json with fumadocs, _meta.* with nextra, or an astro.config.* with starlight().
|
||||||
|
---
|
||||||
|
|
||||||
|
# Migrate to Blume
|
||||||
|
|
||||||
|
Blume is a **markdown-first** documentation framework on Astro/Vite. You drop Markdown/MDX into a folder and get navigation, search, theming, Open Graph images, and a component library with no app boilerplate — **the framework is the template**. There is no starter to clone; the only thing a project owns is its content and a `blume.config.ts`.
|
||||||
|
|
||||||
|
Your job is to convert a source docs repo into an **idiomatic** Blume project — not a 1:1 transliteration. Read this file, detect the source framework, open the matching `references/<framework>.md` for the exact mappings, and work the loop below. Report everything you drop or approximate.
|
||||||
|
|
||||||
|
## Migration philosophy
|
||||||
|
|
||||||
|
- **Target idiomatic Blume, not a mechanical port.** Prefer filesystem-derived navigation over an exhaustive explicit `navigation.sidebar`. Prefer `:::` directives over JSX callouts. Prefer Blume defaults over restating them in config.
|
||||||
|
- **Every field has a default; `{}` is a valid config.** Map only what the source _declares_. If the source uses a framework default, don't write it.
|
||||||
|
- **Drop chrome that has no Blume equivalent — and say so.** Navbar CTAs, footer columns, custom theming, dynamic redirects, and unmappable icons get reported to the user, not silently discarded or faked.
|
||||||
|
- **Convert, don't preserve.** Blume's page frontmatter schema is **strict** — unknown keys are build errors. A source-only frontmatter key must be mapped to a Blume key or removed (and reported), never left to "maybe validate."
|
||||||
|
|
||||||
|
## Migration workflow
|
||||||
|
|
||||||
|
1. **Detect the source framework** and read its reference file:
|
||||||
|
- `docs.json` / `mint.json` → **Mintlify** (`references/mintlify.md`) — the deepest, config-declared nav.
|
||||||
|
- `docusaurus.config.*` → **Docusaurus** (`references/docusaurus.md`).
|
||||||
|
- `meta.json` + `fumadocs-*` deps (content under `content/docs/`) → **Fumadocs** (`references/fumadocs.md`).
|
||||||
|
- `_meta.{js,ts,json}` + `nextra` deps → **Nextra** (`references/nextra.md`).
|
||||||
|
- `astro.config.*` calling `starlight({…})` → **Starlight** (`references/starlight.md`).
|
||||||
|
- Anything else → apply this file's mental model directly; there's no framework-specific reference, so inventory by hand.
|
||||||
|
- **Also note the host repo, independent of source framework:** a pnpm/Turbo workspace, a non-`docs/` content layout, or a Vercel deploy each need integration steps (`content.root` scoping, `minimumReleaseAge`, lockfile, `vercel.json`, an Astro/Vite patch) — all in `references/monorepo.md`. Read it whenever the target isn't a bare single-package docs folder.
|
||||||
|
2. **Inventory the repo** before changing anything: the config file(s), the content tree, the nav definition, snippets/partials/includes, static assets, OpenAPI/AsyncAPI specs, redirects, i18n locales, custom components, and icon usage. Note what's declared vs. defaulted.
|
||||||
|
3. **Write `blume.config.ts`** with `defineConfig` from `blume`. Map only declared fields (see the reference's mapping table); rely on defaults everywhere else. A minimal result is `defineConfig({ title: "…" })`.
|
||||||
|
4. **Restructure content.** Choose `content.root` (default `docs`) — **detect where `.md`/`.mdx` actually live, don't assume a `docs/` folder.** Many repos keep content directly under an app dir (`apps/docs/api/`, `.../getting-started/`) with no `docs/` subfolder; when so, set `content.root` to that dir and scope `content.include` to the real content folders rather than leaving a bare `content.root: "."` that scans everything (see `references/monorepo.md` §1). Order with numeric prefixes (`01-intro.mdx`), group without a URL segment via `(group)/` folders, and add a `meta.ts` (`defineMeta`) only where filesystem order isn't enough. **A source that already declares per-folder navigation in a sidecar file — Fumadocs `meta.json`, Nextra `_meta.*` — _is_ that case: convert each one to a `meta.ts`, carrying over its title/icon/order/collapse, rather than dropping it and hoping filenames reproduce the intent. Filesystem inference is the fallback for folders that declare no per-folder nav, never a reason to discard one that does.** Reach for an explicit `navigation.sidebar` only when the source nav genuinely can't be expressed by files. **Reshaping into folder-per-tab moves URLs** — track every old→new path as you go; you'll turn them into `redirects` in step 5.
|
||||||
|
5. **Rewrite pages.** Map frontmatter to Blume's strict schema; convert callout JSX to `:::` directives — **directives (and math/mermaid/package-install fences) are MDX-only, so rename any `.md` page that needs them to `.mdx`**; rename components; inline snippets/partials (Blume has no import-based includes); fix asset paths; **rewrite internal links** to their new routes (including OpenAPI operation links — see the OpenAPI section, their slugs differ from most sources); **add a `redirects` entry for every route you moved** in step 4; **convert every icon name to Lucide** (Blume is Lucide-only — no FontAwesome/Tabler). Remove any duplicated H1 in the body (`title` renders the H1; bodies start at `##`). **If the source has a hand-maintained changelog and the repo is open source on GitHub, offer to swap it for the `github-releases` source** (see "Changelogs" below) rather than porting the entries. For **Mintlify**, run the bundled codemod first — `node <skill>/scripts/mintlify-codemod.mjs --write <content-dir>` deterministically remaps icons and drops/renames unsupported frontmatter keys, and reports the rest (unknown icons, OpenAPI-stub flags) for you to finish by hand (see `references/mintlify.md`).
|
||||||
|
6. **Adopt `package.json`.** Repoint `dev`/`build`/`start` → `blume dev`/`blume build`/`blume preview`, remove the old framework's deps, add `blume`. A config-only source (e.g. a bare Mintlify `docs.json`) has no manifest — scaffold one. **In a pnpm workspace:** if `pnpm-workspace.yaml`/`.npmrc` sets `minimumReleaseAge`, add **only** `blume` to `minimumReleaseAgeExclude` (don't disable the guard) so the just-published version installs. **Always regenerate the lockfile in the same change:** after editing deps run a plain `pnpm install` (from the workspace root) and commit `pnpm-lock.yaml` alongside `package.json` — CI/Vercel use `--frozen-lockfile`, so a stale lockfile fails the build before it starts. **If the repo uses (or the user wants) [Ultracite](https://www.ultracite.ai) for formatting:** its oxfmt formatter mangles the `:::` directives you just wrote unless you ship the bundled `assets/oxfmt@0.55.0.patch` and register it under `patchedDependencies` — see `references/monorepo.md` §6. See `references/monorepo.md` §2–3.
|
||||||
|
7. **Wire up the host repo & deploy (non-trivial repos).** For a monorepo on Vercel, emit the root-aware install/build recipe and `apps/docs/vercel.json`, and tell the user the two settings you can't commit (Vercel Root Directory, Node 22). If the workspace pins Vite and `blume build` crashes inside Astro/Vite, apply the pnpm-patch workaround. All copy-pasteable in `references/monorepo.md` §4–5.
|
||||||
|
8. **Verify.** Run `blume build --strict` (frontmatter schema, duplicate routes, config — **without `--strict` a build exits 0 despite content errors**, silently dropping invalid pages) and `blume validate --strict` (internal links, heading anchors, assets — the link checker lives in `validate`, not `build`), fix diagnostics, then `blume dev` for a visual pass. End with a written summary of what was migrated, dropped, and approximated — **and every repo-specific edit you made** (pnpm-workspace, vercel.json, config globs) with the reason, plus any manual step left to the user (the Astro patch, Vercel dashboard settings).
|
||||||
|
|
||||||
|
## The Blume mental model
|
||||||
|
|
||||||
|
The single biggest shift for most sources — especially Mintlify — is that **navigation is derived from the filesystem**, not declared in config.
|
||||||
|
|
||||||
|
### Navigation is the file tree
|
||||||
|
|
||||||
|
- **Folders become groups, files become pages.** A page's sidebar label is its frontmatter `title`; a group's label is the humanized folder name.
|
||||||
|
- **Ordering resolves highest-priority-first:** an explicit `navigation.sidebar` (replaces the whole tree) → a folder's `meta.ts` `pages` array → a page's frontmatter `sidebar.order` → the filesystem (`index` first, then numeric filename prefix like `01-`, then alphabetical).
|
||||||
|
- **`meta.ts` refines one folder** (`defineMeta({ title, icon, order, collapsed, pages })`). The `pages` array lists children by slug (numeric prefix and parentheses stripped); children you omit fall back to their own `sidebar.order`, then filesystem order (`index` still sorts first) — so a partial `pages` list is safe, but list every child when the source declared a complete order.
|
||||||
|
- **Sidebar render mode is global, not per-folder.** `navigation.sidebar.display` in `blume.config.ts` is `"flat"` (default), `"group"` (collapsible), or `"page"` (drill-in sub-panel) and applies to **every** group at once. (It used to live on each folder's `meta.ts` as `display`; that field was **removed** — writing it in a `meta.ts` is now a build error. Set it once in config instead.) An explicit `navigation.sidebar` item may still override its own group's `display`.
|
||||||
|
- **An explicit `navigation.sidebar` replaces filesystem generation entirely.** Use it only for a nav shape files can't express. Its items are a page route string, a group (`{ label, items }`), or a link (`{ label, href }`).
|
||||||
|
- **Config-declared nesting has no on-disk counterpart — materialize it or it flattens silently.** When a source (Mintlify `groups`, Nextra `_meta`, a Docusaurus sidebar…) declares a nested group, its pages usually sit **flat in one folder** and the grouping lives only in config. Filesystem-derived nav sees the flat folder and drops the inner group. To keep the nesting you must **either** move those pages into a real subfolder (`meta.ts` for label/`collapsed`) — which changes their URLs, so add `redirects` — **or** declare the group in an explicit `navigation.sidebar`, which nests the existing routes without moving a file. Walk config `pages`/nav arrays **recursively** during inventory and record where config nesting depth exceeds on-disk depth; that gap is exactly what gets lost.
|
||||||
|
|
||||||
|
### Tabs and selectors
|
||||||
|
|
||||||
|
- **`navigation.tabs`** (`{ label, path, icon? }`) render top-of-header sections and **scope the sidebar by route** — the folder at a tab's `path` becomes the section, so this needs no config beyond the tabs themselves; structure content as **one folder per tab**. **A source's top-level tabs (Mintlify `navigation.tabs`, a top-level product/section switcher) map to these header tabs — keep them as tabs; don't flatten them into a single global `navigation.sidebar`.** Blume picks the active tab by **URL prefix** (longest tab `path` that prefixes the route), so every page in a tab must live under that tab's single `path`; a source tab that mixes arbitrary routes isn't portable as-is — either move its pages under one prefix (route change → add `redirects`) or accept the closest shape, and say which in the report (details in `references/mintlify.md`). The filtering runs both ways: on a route **under** a tab's `path`, the sidebar shows **only** that tab's folder (a tab also highlights when the current route is under it); on a **root or untabbed** route (or a tab whose `path` is `/`), the tab folders are **hidden** and the sidebar shows only the loose pages that belong to no tab (full tree as a fallback, so it's never blank). Consequence for migrations: once you add tabs, the landing sidebar automatically drops the sectioned content — that's intended, not lost pages; don't hand-build excludes for it.
|
||||||
|
- **`navigation.selectors`** (`{ kind, label, items: [{ label, path, icon?, description?, tag? }] }`, `kind` = `dropdown`/`product`/`version`/`language`) partition a whole site (products, versions) via a header dropdown keyed on the current route.
|
||||||
|
- **`navigation.featured`** (`{ label, href, icon? }`) pins links to the **top of the sidebar, above every section** — a blog, changelog, or support page that should always be one click away. These are the **exception to tab scoping**: unlike the generated tree, featured links show on **every** route and breakpoint. `href` points anywhere — an external URL opens in a new tab, an internal route (`/contact`) is validated against your pages at build time. `icon` is a Lucide name (or image path/URL/inline SVG), as everywhere else. This is the home for a source's always-visible header/utility links (Mintlify anchors, Blog/Contact links) — see `references/mintlify.md`.
|
||||||
|
|
||||||
|
### Routes and pathing
|
||||||
|
|
||||||
|
- A route is the content path relative to `content.root`, with **numeric prefixes stripped** (`01-intro.mdx` → `/intro`) and **`(group)/` folders adding no segment**. An `index` file maps to its folder's route. Frontmatter `slug` overrides the generated route.
|
||||||
|
|
||||||
|
### `blume.config.ts` shape
|
||||||
|
|
||||||
|
`defineConfig({...})` — every field optional, all with defaults:
|
||||||
|
|
||||||
|
- **Site:** `title`, `description`, `logo` (string SVG, or `{ image: string | { light, dark, alt }, text, href }`), `banner` (`{ content, link, dismissible, id }` — no color/type). A logo renders beside `title` in the header, so a **wordmark logo doubles the brand** ("Acme Acme") — set `text: ""` to render the mark alone. **Prefer the string form over `{ light, dark }`:** if you have the logo SVG locally and it's monochrome (solid black or white), rewrite its `fill`/`stroke` to `currentColor` and use `logo: "/logo.svg"` — it then inherits the theme's text color and adapts to light/dark automatically, so you don't need separate light/dark files.
|
||||||
|
- **`theme`:** `accent` (a color string for both modes, or `{ light, dark }` per mode), `action` (color), `mode` (`light`/`dark`/`system`), `radius`, `fonts` (`{ body, display, mono }` — curated Google-font slugs), `background` and `backgroundImage` (each a string, or `{ light, dark }` per mode). The old `accentDark`/`backgroundDark`/`backgroundImageDark` fields were **merged into these per-mode objects** — a bare string still applies to both modes, so only reach for `{ light, dark }` when the two modes differ. There is **no** `theme.strict` and **no** `theme.css` config field — custom CSS goes in a project-root **`theme.css` file** (auto-picked-up), and a source's "strict appearance" flags drop.
|
||||||
|
- **`content`:** `root` (default `"docs"`, relative to the project dir where `blume` runs), `include`/`exclude` (arrays of globs **relative to `content.root`**; defaults `["**/*.{md,mdx}"]` / `["**/_*", "**/.*"]`), `sources` (staged sources: `filesystem`, `github-releases`, `notion`, `sanity`, `mdx-remote`, `custom` — OpenAPI is **not** one of these; it's the top-level `openapi` field), `pages` (custom `.astro` dir), `defaultType`. When docs sit directly under the project dir (no `docs/` subfolder), set `root` there and **scope `include` to the real content folders** instead of scanning everything — `references/monorepo.md` §1.
|
||||||
|
- **`basePath`** (top-level): a site-wide mount point (e.g. `"/docs"`) prepended to **every** route while staying invisible to the sidebar (no wrapper group). This is the right target for a source that served all docs under a prefix (Docusaurus `routeBasePath`, a Fumadocs `baseUrl` of `/docs`) — distinct from a per-source `prefix` (which adds a nav group) and from `deployment.base` (host subdirectory).
|
||||||
|
- **`navigation`:** `tabs`, `selectors`, `featured` (links pinned above the sidebar on every route), `sidebar` (`{ display, items }` — `display` is the global render mode above; `items` is an explicit tree), `repo`. **Avoid an explicit `navigation.sidebar` unless you have to** — lean on the filesystem-derived sidebar. It only works when the file tree roughly matches the intended sidebar layout, so reshape folders to match first; reach for `sidebar.items` only for a shape files genuinely can't express (see "Config-declared nesting" above).
|
||||||
|
- **`search`** (Orama default, Pagefind opt-in), **`ai`** (llms.txt, Ask AI, the MCP server), **`openapi`**, **`redirects`**, **`seo`**, **`markdown`**, **`analytics`**, **`deployment`**, **`i18n`**, **`toc`**, **`lastModified`**, **`github`**.
|
||||||
|
- **Don't set `deployment.site`.** Blume auto-fills it: the dev server's `localhost` URL in dev, and the deployment URL (`VERCEL_PROJECT_PRODUCTION_URL`/`VERCEL_URL`) on Vercel. Hardcoding it in `blume.config.ts` overrides that auto-detection and pins the wrong absolute URL (canonical links, sitemap, OG, llms.txt) everywhere but the one host you typed — so leave it unset even when a source config had a `url`/`site` field. (Sitemap still generates in production because the deploy URL is present there.)
|
||||||
|
- **Favicon is a filename convention, not config.** Drop `icon`/`favicon.{svg,png,ico}` (and `apple-icon.png`) in the project root or `public/` — Blume auto-detects it. There is **no** `favicon` config field. A source favicon given as `{ light, dark }` **collapses to one** — pick a single file and report the loss.
|
||||||
|
|
||||||
|
The schema is exported from `blume/schema`; the full field reference is in `node_modules/blume/docs/configuration/`.
|
||||||
|
|
||||||
|
### Icons are Lucide, period
|
||||||
|
|
||||||
|
Blume resolves **bare kebab-case [Lucide](https://lucide.dev) names** everywhere an icon is accepted — frontmatter `icon`, `sidebar.icon`, `meta.ts` `icon`, `navigation.tabs`/`selectors` icons, and `Card`/`Step`/`Icon`/etc. props. There is **no** FontAwesome or Tabler support and **no** `iconType` prop. Names must be **kebab-case** (`book-open`, not `BookOpen`) — a PascalCase React-component name (common in Fumadocs/lucide-react sources) does not resolve and renders nothing. When migrating a source that uses another icon set (Mintlify defaults to FontAwesome), **map each name to its closest Lucide equivalent**; where none exists, drop the icon and report it. Verify a name exists at [lucide.dev/icons](https://lucide.dev/icons) before writing it.
|
||||||
|
|
||||||
|
### Page frontmatter (strict — unknown keys are build errors)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: Install # renders as the page H1 — remove any duplicate H1 in the body
|
||||||
|
description: Install Blume and scaffold your first project.
|
||||||
|
type: doc # doc (default) | blog | changelog | api
|
||||||
|
icon: download # a Lucide name
|
||||||
|
sidebar:
|
||||||
|
label: Install # overrides title in the sidebar
|
||||||
|
order: 2
|
||||||
|
icon: download
|
||||||
|
badge: New
|
||||||
|
hidden: false
|
||||||
|
seo:
|
||||||
|
title: …
|
||||||
|
description: …
|
||||||
|
image: /og/install.png
|
||||||
|
canonical: https://…
|
||||||
|
noindex: false
|
||||||
|
search:
|
||||||
|
exclude: false
|
||||||
|
tags: [api]
|
||||||
|
slug: install # override the generated route
|
||||||
|
draft: false
|
||||||
|
lastModified: 2026-06-20 # pin the "last updated" date
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Also valid: `date`/`authors` (blog/changelog feeds), `changelog` (changelog metadata), `deprecated`, `hidden`, `noindex`.
|
||||||
|
|
||||||
|
### Authoring features (no imports needed in `.mdx`)
|
||||||
|
|
||||||
|
- **The rich features are MDX-only.** Directives, `package-install`, mermaid, and math are wired into the MDX processor; in a plain `.md` file a `:::note` stays **literal text** — and the build stays green. **Rename any `.md` file that uses (or should use) these to `.mdx` during migration.** This bites hardest on Docusaurus/Starlight sources, whose `.md` content is full of `:::` admonitions. Plain Markdown (headings, tables, fenced code with titles/highlighting) is fine in `.md`.
|
||||||
|
- **Callouts as directives:** `:::note`, `:::tip`, `:::warning`, `:::danger`, `:::info`, `:::success`, with an optional title in brackets: `:::warning[Heads up]`. Aliases `caution`→warning, `error`→danger, `important`→note, `warn`→warning.
|
||||||
|
- **No-import MDX components:** `Callout`, `Card`/`CardGroup`, `Columns`/`Column`, `Steps`/`Step`, `Tabs`/`Tab`, `Accordion`/`AccordionItem`, `Expandable`, `FileTree`, `Tree`/`Tree.Folder`/`Tree.File`, `CodeGroup`, `Frame`, `Panel`, `Tooltip`, `Tile`, `Badge`, `Icon`, `TypeTable`/`AutoTypeTable`, `Color`, `YouTube`, `Visibility`, `GithubInfo`, `Component`, `CodeBlock`, `Diff`, `Prompt`, `Math`. (**Not** shipped — convert away: `<Warning>` → the `:::warning` directive, and the `ParamField`/`ResponseField`/`RequestField` field family → `TypeTable` rows or the OpenAPI reference. See the reference files for targets.)
|
||||||
|
- **Fenced-code superpowers:** ` ```package-install ` → package-manager tabs; ` ```mermaid ` → a rendered diagram; code-block titles (` ```ts server.ts `), line numbers (`lineNumbers`), and highlighting (`{1,4-5}`, `// [!code ++]`).
|
||||||
|
- **Math:** block math `$$…$$` renders in `.mdx` with **no config** (there is no `markdown.math` field). Inline `$…$` is **not** supported — a bare `$` stays literal text; convert inline math to display math or drop it (report).
|
||||||
|
|
||||||
|
### OpenAPI
|
||||||
|
|
||||||
|
`openapi: { enabled: true, sources: [{ spec, label?, route? }] }` generates **one real page per operation** — with routing, sidebar, search, and OG images for free. **The reference does not get a header tab automatically** — add a `navigation.tabs` entry pointing at the reference's `route` (reference routes are valid tab targets) or the API reference is unreachable from the header. **Never hand-migrate generated API-reference pages** (per-endpoint stub pages in the source): delete them and point `openapi.sources` at the spec. (`renderer: "scalar"` keeps the Scalar embed instead; AsyncAPI uses the same embed.)
|
||||||
|
|
||||||
|
- **Vendor the spec by default.** A remote `spec:` URL makes every build depend on fetching it at build time — a single point of failure in CI, offline, or behind a proxy, and a failed fetch skips the whole reference. Prefer committing the spec into the repo (`openapi/<name>.json`) and pointing `spec` at the local path; if you keep the URL, say so and consider a `prebuild` step that refreshes the local copy with a fallback.
|
||||||
|
- **Operation routes have their own slug scheme** — `<route>/<slugified-tag>/<slugified-operationId>` (e.g. tag `Models`, id `listModels` → `/api-reference/models/listmodels`). This rarely matches the source's endpoint links (Mintlify/others kebab-case differently), so **rewrite every inbound link to an operation**. `blume validate` resolves operation pages like any other route, so it catches the ones you miss.
|
||||||
|
- **Keep hand-written conceptual pages.** Sources often pair a written "Introduction/Authentication" page with the endpoint group in the same tab. A normal content page placed under the openapi `route` merges into the reference tab's sidebar — so keep those (auth, errors, rate limits) and delete only the per-endpoint stubs.
|
||||||
|
|
||||||
|
### Changelogs
|
||||||
|
|
||||||
|
If the source ships a **hand-maintained changelog** (a `changelog.mdx`, a folder of dated entries, Mintlify `<Update>` blocks) **and the project is open source on GitHub**, offer to replace it with the **`github-releases`** content source — release notes become the changelog automatically, with no files to maintain. It's an offer, not an automatic rewrite: some teams keep a curated changelog that doesn't map 1:1 to GitHub releases, so confirm the release notes are the source of truth before deleting their pages.
|
||||||
|
|
||||||
|
Add it under `content.sources` alongside the filesystem source:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
content: {
|
||||||
|
sources: [
|
||||||
|
{ include: ["docs/**/*.mdx"], root: ".", type: "filesystem" },
|
||||||
|
{
|
||||||
|
owner: "haydenbleasel",
|
||||||
|
repo: "ultracite",
|
||||||
|
prefix: "changelog",
|
||||||
|
type: "github-releases",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- Each release materializes as a `type: changelog` page under `/<prefix>/` (`prefix: "changelog"` → `/changelog/…`); omit `prefix` to mount at the root.
|
||||||
|
- Optional fields: `limit` (cap materialized releases, newest-first, default 100), `prereleases` (include prereleases), `drafts` (include drafts — needs a token with repo write access), `pollInterval` (dev polling seconds; omit to freeze for the session).
|
||||||
|
- A **private** repo reads a token from `GITHUB_TOKEN`; it is never inlined in config. A public repo needs no token.
|
||||||
|
- **Delete the old changelog pages** once the source is wired (and add `redirects` from their old routes to the new `/<prefix>/…` slugs). Pin a header/sidebar link with `navigation.featured` if the source had one.
|
||||||
|
|
||||||
|
### Redirects are static
|
||||||
|
|
||||||
|
A `redirects: [{ from, to, status? }]` array **in `blume.config.ts`** maps old URLs when you restructure routes — Blume serves these itself, so any reorganization that moves a page (folder-per-tab, materialized nested groups, renamed slugs, index promotion) is fixed by adding an entry there; no host config needed. **Restructuring is the main source of these:** every page you moved in step 4 (folder-per-tab, renamed slugs, index promotion) needs an entry, or old URLs 404. `status` defaults to **301 (permanent — browsers cache it indefinitely)**; that's correct for genuine moves, but never use 301/308 for redirects you might reverse. Dynamic/wildcard patterns (`:slug*`) can't be modeled as static path-to-path; move those to host-level config (`_redirects`, `vercel.json`) and report them.
|
||||||
|
|
||||||
|
## Verification & reporting
|
||||||
|
|
||||||
|
1. Run **`blume build --strict`** — it validates the frontmatter schema, duplicate routes, and config, and `--strict` makes diagnostics fail the build (without it, `blume build` **exits 0 despite content errors** and silently drops invalid pages). Then run **`blume validate --strict`** — links, heading anchors, and assets live here, not in `build` (add `--external` to also check outbound HTTP links). OpenAPI operation pages are real routes to `validate`, so dead links to them are caught too. Iterate until both are clean.
|
||||||
|
2. Run `blume dev` and review the site visually — nav structure, tabs, theme, rendered components.
|
||||||
|
3. **Write a migration summary** covering: what was migrated (config, N pages, nav, OpenAPI), what was **dropped** (navbar CTAs, footers, custom theming, dynamic redirects, unmappable icons, unsupported components), and suggested follow-ups (`blume eject` for full control, `blume add` to vendor a component for customization).
|
||||||
|
|
||||||
|
## Full documentation
|
||||||
|
|
||||||
|
The mapping details live in `references/`: one file per source framework (`mintlify.md`, `docusaurus.md`, `fumadocs.md`, `nextra.md`, `starlight.md`), plus **`monorepo.md`** for host-repo integration (content-layout detection, pnpm `minimumReleaseAge`, frozen-lockfile regeneration, the Vercel monorepo recipe, and the Astro/Vite patch). The Mintlify icon + frontmatter pass is automated by **`scripts/mintlify-codemod.mjs`** (zero-dependency, deterministic, idempotent; `--write` to apply). The authoritative Blume docs are bundled in the installed package at **`node_modules/blume/docs`** (or `apps/docs/content/docs` in a repo checkout). The most relevant pages:
|
||||||
|
|
||||||
|
- `configuration/index.mdx` — every `blume.config.ts` field.
|
||||||
|
- `content/navigation.mdx` — the sidebar/tabs/selectors model.
|
||||||
|
- `content/meta.mdx` — `meta.ts` and display modes.
|
||||||
|
- `content/syntax.mdx` — directives, code features, math.
|
||||||
|
- `content/components.mdx` — the component library and APIs.
|
||||||
|
- `reference/frontmatter.mdx` — the strict page schema.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
diff --git a/dist/markdown-Bowe09rB.js b/dist/markdown-Bowe09rB.js
|
||||||
|
index 0fe63ff712e07c9c2188711fa0940f7a7d8f7d74..44435d91a2f5a6711512981c9bcaddedc1760be4 100644
|
||||||
|
--- a/dist/markdown-Bowe09rB.js
|
||||||
|
+++ b/dist/markdown-Bowe09rB.js
|
||||||
|
@@ -1141,7 +1141,14 @@ function Yf(e, r, t) {
|
||||||
|
case "sentence": return Yi(e, t);
|
||||||
|
case "word": return Li(e);
|
||||||
|
case "whitespace": {
|
||||||
|
- let { next: i } = e, u = i && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/u.test(i.value) ? "never" : r.proseWrap;
|
||||||
|
+ let { next: i, previous: oxfmtFencePrev } = e;
|
||||||
|
+ // Preserve line breaks that sit directly against a `:::` container
|
||||||
|
+ // directive fence, so `proseWrap: "never"` keeps the opening/closing
|
||||||
|
+ // fence on their own lines instead of joining them into the prose (which
|
||||||
|
+ // breaks the directive). Ordinary prose still wraps per proseWrap.
|
||||||
|
+ // See prettier/prettier#19040.
|
||||||
|
+ let oxfmtIsFence = (w) => w != null && typeof w.value === "string" && w.value.startsWith(":::");
|
||||||
|
+ let u = oxfmtIsFence(oxfmtFencePrev) || oxfmtIsFence(i) ? "preserve" : i && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/u.test(i.value) ? "never" : r.proseWrap;
|
||||||
|
return qt(e, n.value, u);
|
||||||
|
}
|
||||||
|
case "emphasis": {
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Docusaurus → Blume
|
||||||
|
|
||||||
|
Docusaurus is a React site; navigation comes from `sidebars.js` (explicit or autogenerated) plus per-folder `_category_.json`. Admonitions are already directive syntax, which eases the content transform — **but note the source major version**: v2 (MDX v1) and v3 (MDX v3) have different content hazards, called out below.
|
||||||
|
|
||||||
|
## Detect
|
||||||
|
|
||||||
|
- **`docusaurus.config.{js,ts}`** at the root; a **`sidebars.{js,ts}`** file and per-folder **`_category_.json`** files.
|
||||||
|
- Content under **`docs/`** (often plus `blog/`, `src/pages/`, `static/`, `versioned_docs/`); `@docusaurus/*` deps.
|
||||||
|
- v2 vs v3: check the `@docusaurus/core` major in `package.json`.
|
||||||
|
|
||||||
|
## Config: `docusaurus.config` → `blume.config.ts`
|
||||||
|
|
||||||
|
Read `themeConfig`, `presets`, and `plugins`:
|
||||||
|
|
||||||
|
| Docusaurus | Blume |
|
||||||
|
| --- | --- |
|
||||||
|
| `title` | `title` |
|
||||||
|
| `tagline` | `description` |
|
||||||
|
| `themeConfig.navbar.title` / `.logo` | `title` / `logo` (move the image into `public/`) |
|
||||||
|
| `themeConfig.navbar.items` (doc items) | `navigation.tabs` (for section links) |
|
||||||
|
| `themeConfig.navbar.items` (external/utility links — Blog, GitHub, Discord…) | **`navigation.featured`** (`{ label, href, icon? }`, pinned above the sidebar on every route); the GitHub link → the `github` config instead |
|
||||||
|
| `themeConfig.colorMode.defaultMode` | `theme.mode` (`respectPrefersColorScheme: true` → `"system"`) |
|
||||||
|
| `themeConfig.prism.theme` / `.darkTheme` | `markdown.codeBlocks.theme: { light, dark }` (map Prism theme names to Shiki themes, e.g. `github`/`github-dark`) |
|
||||||
|
| `themeConfig.metadata` / `themeConfig.image` | per-page `seo` frontmatter / `seo.og`; report what doesn't fit |
|
||||||
|
| `url` + `baseUrl` | **`url` → drop** (`deployment.site` auto-detects); `baseUrl` (when not `/`) → `deployment.base` |
|
||||||
|
| preset `docs.routeBasePath` — **including the default!** | Docusaurus serves docs at **`/docs/…` by default**; the "map only declared fields" rule does **not** apply here because the _URLs_ are load-bearing. Either keep them with top-level **`basePath: "/docs"`** (invisible to the sidebar), or intentionally move to root and emit a `redirects` entry per page. Decide explicitly and say which. (`routeBasePath: '/'` = docs-only mode — nothing to do.) |
|
||||||
|
| preset `docs.editUrl` | `github` (owner/repo/branch; a path after the branch → `github.dir`) |
|
||||||
|
| `themeConfig.footer` | drop → Footer override (`defineComponents` layout slot) |
|
||||||
|
| `themeConfig.announcementBar` | `banner` (`{ content, dismissible, id }` — `isCloseable` → `dismissible`; colors drop) |
|
||||||
|
| `i18n.locales` / `defaultLocale` | `i18n` — translated files live at `i18n/<locale>/docusaurus-plugin-content-docs/current/…`; move them to `<locale>/…` under `content.root` |
|
||||||
|
| `themeConfig.algolia` | drop — Blume ships built-in search (Orama); remove the Algolia dep |
|
||||||
|
| `@docusaurus/plugin-client-redirects` | **static `redirects: [{from, to}]` arrays convert 1:1** to Blume `redirects` (a `from` array = one entry per item); only `createRedirects` functions are truly dynamic → host rules |
|
||||||
|
| `@docusaurus/theme-mermaid` | delete the dep — ` ```mermaid ` renders natively (in `.mdx`) |
|
||||||
|
| `remark-math` + `rehype-katex` | delete — block `$$…$$` renders in `.mdx` with no config (no `markdown.math` field exists); **inline `$…$` is not supported** — convert or drop (report) |
|
||||||
|
| Multi-instance docs plugins (`plugin-content-docs` with `id`) | one folder (and usually one `navigation.tabs` entry) per instance |
|
||||||
|
|
||||||
|
## Static assets — required move
|
||||||
|
|
||||||
|
Every Docusaurus repo serves **`static/`** at the site root (`static/img/foo.png` → `/img/foo.png`). Blume serves **`public/`**. **Move `static/*` (and any extra `staticDirectories`) into `public/`** — all root-relative references then keep resolving unchanged. Without this, every image and download 404s.
|
||||||
|
|
||||||
|
## Navigation: `sidebars.js` + `_category_.json`
|
||||||
|
|
||||||
|
- **Autogenerated sidebar** (`{ type: 'autogenerated', dirName: '...' }`) → Blume's default filesystem navigation. Docusaurus strips numeric prefixes (`01-`) exactly like Blume, so the convention round-trips; no config needed.
|
||||||
|
- **Explicit sidebar** (arrays of doc IDs, categories, links) → restructure into folders where possible; use `navigation.sidebar` only for shapes files can't express. A category `{ type: 'category', label, items }` → a folder (label → `meta.ts` `title`); `collapsed` → `meta.ts` `collapsed` (**no `display` field in `meta.ts`** — if collapse matters, set the global `navigation.sidebar.display: "group"` once in config).
|
||||||
|
- **`_category_.json`** (also `.yml`) (`{ label, position, collapsed, collapsible, link, className, customProps }`) → a folder `meta.ts`: `label`→`title`, `position`→`order`, `collapsed`→`collapsed`. `link.type: 'generated-index'` → an `index` page in the folder — **and the old URL was `/docs/category/<slug>`**, so add a redirect and rewrite inbound links. `link.type: 'doc'` → make that doc the folder's `index`. `collapsible`/`className`/`customProps` → drop (report).
|
||||||
|
- **`src/pages/` — inventory, don't nuke.** Nearly every repo has a React landing page (`src/pages/index.tsx`) and often extra Markdown pages. Markdown pages → content pages; the React home page → rebuild as a docs index or a custom `.astro` page under `content.pages` — report either way.
|
||||||
|
|
||||||
|
## Versioned docs
|
||||||
|
|
||||||
|
Docusaurus `versioned_docs/version-X/` + `versions.json` (+ `versioned_sidebars/`) → **recommend migrating the latest released version only**. Mind the URL scheme: by default the **latest release** serves at `/docs/` and the work-in-progress `docs/` folder serves at `/docs/next` (`lastVersion: 'current'` flips this) — pick the folder that matches what users see at `/docs/`. If older versions must stay, put each under its own folder and wire a `navigation.selectors` entry of `kind: "version"`.
|
||||||
|
|
||||||
|
## Blog
|
||||||
|
|
||||||
|
A Docusaurus `blog/` → Blume `type: blog` pages. **Dates come from filenames/folders** (`2024-01-31-foo.md`) — extract each into `date` frontmatter and strip the date from the filename (the old dated URLs `/blog/2024/01/31/foo` need `redirects`). Strip `<!-- truncate -->` / `{/* truncate */}` markers. `authors.yml` refs → inline author objects in each post's `authors` frontmatter. RSS stays at `/blog/rss.xml` on both sides.
|
||||||
|
|
||||||
|
## Content & components
|
||||||
|
|
||||||
|
- **`.md` vs `.mdx` — both majors need renames, for opposite reasons.** Blume parses `.md` as plain Markdown: no directives, no JSX, no `$$` math, no mermaid/package-install fences. **v3** treats `.md` as MDX (so a `.md` with imports/JSX/`{}` renders them as literal text in Blume); **v2** content is looser MDX v1. Rule: **rename any `.md` that contains admonitions, JSX, imports, or math to `.mdx`** — for typical Docusaurus repos that is most files.
|
||||||
|
- **Admonitions are directives — but check the version.** v3: `:::note`, `:::tip`, `:::info`, `:::warning`, `:::danger` pass through; `:::caution` → `:::warning` (or rely on Blume's alias); titles `:::note[Title]` work. **v2:** titles are space-separated (`:::note Your Title`) — rewrite to brackets or the title is silently lost; and v2's `:::warning` rendered **red/danger** — audit whether it should become `:::danger`.
|
||||||
|
- **Tabs:** `<Tabs>`/`<TabItem label="…" value="…">` → `<Tabs>`/`<Tab title="…">`. Drop `groupId`/`queryString`/`value`; strip the `@theme/Tabs` imports.
|
||||||
|
- **Theme JSX in content:** `<DocCardList/>` (standard on category index pages) → hand-write `Card`/`CardGroup` links or delete (a Blume group page lists its children); `<TOCInline/>` → drop (report); `<CodeBlock>` JSX → a fenced code block; `<Admonition>` → the matching directive; `<details>`/`<summary>` → `<Accordion>`/`<AccordionItem>` or leave as raw HTML.
|
||||||
|
- **`@theme/*` / `@site/*` imports** — strip `@theme/*` (Blume injects components globally); rewrite `@site/` asset/module paths to `/public` URLs or inline. **MDX partials** (`_partial.mdx` imports) → inline the partial's body (Blume's default `**/_*` exclude already hides the partial files themselves).
|
||||||
|
- **Code blocks:** `title="file.js"` → works as-is; `showLineNumbers` → `lineNumbers`; **magic comments** (`// highlight-next-line`, `highlight-start`/`end`) → `{ranges}` or `// [!code highlight]` — unconverted they ship as literal comments in every sample; ` ```bash npm2yarn ` → ` ```package-install `.
|
||||||
|
- **MDX v1 (v2 sources) pitfalls:** unescaped `<`/`{` in prose, HTML comments `<!-- -->` (→ `{/* */}`), string `style="…"` attributes (→ objects). Fix as build errors surface.
|
||||||
|
|
||||||
|
## Frontmatter
|
||||||
|
|
||||||
|
| Docusaurus | Blume |
|
||||||
|
| --- | --- |
|
||||||
|
| `title` / `description` | pass through |
|
||||||
|
| `id` | usually drop (routing is filesystem-based); use `slug` to pin a route |
|
||||||
|
| `slug` | `slug` |
|
||||||
|
| `sidebar_label` | `sidebar.label` |
|
||||||
|
| `sidebar_position` | `sidebar.order` |
|
||||||
|
| `unlisted` | `hidden: true` + `noindex: true` |
|
||||||
|
| `last_update.date` | `lastModified` |
|
||||||
|
| `draft` | `draft` |
|
||||||
|
| `tags` | `search.tags` (or drop if only used for the blog tag index) |
|
||||||
|
| `image` | `seo.image` |
|
||||||
|
| `keywords` | drop → use `seo` |
|
||||||
|
| `sidebar_class_name` / `sidebar_custom_props` / `pagination_next` / `pagination_prev` / `pagination_label` / `hide_title` / `hide_table_of_contents` / `toc_min_heading_level` / `toc_max_heading_level` / `displayed_sidebar` / `custom_edit_url` | drop (report) |
|
||||||
|
|
||||||
|
Remove any duplicated H1 in the body — `title` renders the H1.
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
Docusaurus has no built-in icon-name system (icons are usually inline SVG/emoji/CSS). Where you add sidebar/tab icons, use Lucide names. Convert any FontAwesome/react-icons usage in content to Lucide `<Icon>` or drop it.
|
||||||
|
|
||||||
|
## Teardown
|
||||||
|
|
||||||
|
Remove `@docusaurus/*` and Algolia deps; delete `docusaurus.config.*`, `sidebars.*`, `babel.config.js`, and `src/` theme customizations (after the `src/pages/` inventory above; re-implement what matters via `defineComponents` layout slots or `blume eject`). Repoint `start`/`build`/`serve` scripts → `blume dev`/`blume build`/`blume preview`; add `blume`.
|
||||||
|
|
||||||
|
## Dropped — report these
|
||||||
|
|
||||||
|
Custom/swizzled theme components (layout slots or `blume eject`), footer columns, Algolia config, `sidebar_custom_props` and the other dropped frontmatter keys, `createRedirects` functions (→ host rules), React pages under `src/pages/`, `<TOCInline>`, per-category `className`/`customProps`, and any `@theme/*` component with no Blume equivalent.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Fumadocs → Blume
|
||||||
|
|
||||||
|
Fumadocs is code-first: navigation comes from the folder tree + per-folder `meta.json`. This maps cleanly to Blume — most content passes through, and `meta.json` becomes `meta.ts`. Fumadocs v16 runs on Next.js, React Router, TanStack Start, Astro, or Waku — detect by the Fumadocs files, not by Next.js files, and adjust the teardown list to whichever host framework is present.
|
||||||
|
|
||||||
|
## Detect
|
||||||
|
|
||||||
|
- Content under **`content/docs/`** (the Fumadocs convention).
|
||||||
|
- Per-folder **`meta.json`** files.
|
||||||
|
- `fumadocs-ui` / `fumadocs-core` / `fumadocs-mdx` deps and a `source.config.ts`.
|
||||||
|
- A `loader({ baseUrl: "/docs" })` call in `lib/source.ts` (or `app/source.ts`, `src/lib/source.ts`, `source.ts`).
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
Fumadocs declares almost nothing Blume needs — only two things map:
|
||||||
|
|
||||||
|
- **Title** — read `package.json` `name`, prettified (drop scope, split on `-_`, Title-Case). For a generic monorepo name (`web`, `app`, `docs`…), use the repo-root directory name instead.
|
||||||
|
- **Route prefix** — the `baseUrl` in the source loader (required in Fumadocs, so it's always declared). If it's a real prefix (e.g. `/docs`), set top-level **`basePath: "/docs"`** — it prefixes every route while staying invisible to the sidebar, which matches Fumadocs' behavior. (A `content.sources` `prefix` also works but adds a wrapping nav group — only use it when you _want_ the group.) If `baseUrl` is `"/"`, serve from the site root (nothing to set).
|
||||||
|
|
||||||
|
Everything else is `defineConfig({ title })`.
|
||||||
|
|
||||||
|
**Also read `source.config.ts` `mdxOptions`** — remark/rehype plugins live there:
|
||||||
|
|
||||||
|
- `remark-math`/`rehype-katex` → nothing to configure: block math `$$…$$` renders in `.mdx` out of the box (there is **no** `markdown.math` field). Inline `$…$` is **not** supported — convert inline math to `$$…$$` or drop it (report).
|
||||||
|
- A Twoslash transformer → Blume supports the `twoslash` fence meta natively; drop the plugin.
|
||||||
|
- Custom Shiki transformers/themes or other plugins → report.
|
||||||
|
|
||||||
|
## Navigation: `meta.json` → `meta.ts`
|
||||||
|
|
||||||
|
**Every `meta.json` becomes a `meta.ts` — this is the primary navigation carry-over for Fumadocs, always required, never optional.** `meta.json` is Fumadocs' canonical nav source; no filesystem-only shortcut reproduces its ordering, icons, and collapse state, so don't skip it in favor of filename inference. Move content from `content/docs/` to your chosen `content.root` (e.g. `docs/`), then convert each `meta.json` to a `meta.ts` (`defineMeta`):
|
||||||
|
|
||||||
|
| Fumadocs `meta.json` | Blume `meta.ts` |
|
||||||
|
| --- | --- |
|
||||||
|
| `title` | `title` |
|
||||||
|
| `icon` | `icon` — **convert the casing** (see Icons below) |
|
||||||
|
| `defaultOpen: false` | `collapsed: true` |
|
||||||
|
| `defaultOpen: true` | `collapsed: false` |
|
||||||
|
| `root: true` | a **`navigation.tabs` entry** (see below) — **not** a `meta.ts` field |
|
||||||
|
| `pages: [...]` slugs | `pages: [...]` (ordering) |
|
||||||
|
| `description` | **drop** (folders have no description) |
|
||||||
|
| `collapsible: false` | **drop** (report — no per-folder equivalent) |
|
||||||
|
|
||||||
|
`meta.ts` accepts **only** `title`, `icon`, `order`, `collapsed`, `pages` — there is **no `display` field** (writing one is a build error). Collapse rendering is global: when any folder needs collapsing, set `navigation.sidebar.display: "group"` once in `blume.config.ts`.
|
||||||
|
|
||||||
|
**`root: true` folders are Fumadocs' tab mechanism** — Fumadocs UI renders them as layout tabs and scopes the sidebar to the active one. That is exactly Blume's `navigation.tabs`: add `{ label, path, icon? }` per root folder (label/icon from its `meta.json`), pointing at the folder's route. Blume then scopes the sidebar by URL prefix the same way. Don't try to model root folders inside `meta.ts`.
|
||||||
|
|
||||||
|
Handle the `pages` array items:
|
||||||
|
|
||||||
|
- **`"..."`** (rest marker) / `""` → drop; Blume appends unlisted pages automatically. **`"z...a"`** (reversed rest) → no equivalent; list the pages explicitly in the intended order.
|
||||||
|
- **`"!page"`** (exclusion) → Blume _appends_ unlisted pages, so a dropped `!page` **resurfaces in the sidebar**. Set that page's frontmatter `sidebar.hidden: true` instead.
|
||||||
|
- **`"---Section---"`** (separator; also the `---[Icon]Label---` variant) → Blume has no flat separator. Turn each section into a **`(Section)/` group folder** (route-transparent — the `(…)` segment is stripped from URLs), and move the section's pages into it. If a section wraps a single existing folder, leave it in place and set that folder's `meta.ts` `title` instead.
|
||||||
|
- **`"...folder"`** (extract) → Blume can't flatten a folder inline; keep it as a normal group at that ordering position and report it.
|
||||||
|
- **`"[Text](url)"`** (link; also `[Icon][Text](url)` and `external:` variants) → a top-level/utility link belongs in **`navigation.featured`** (`{ label, href, icon? }` — pinned above the sidebar on every route). A link buried deep in one folder has no folder-meta home — drop and report (or model that one folder via an explicit `navigation.sidebar`).
|
||||||
|
- **`pagesIndex`** → make that page the folder's `index` (rename the file); report the route change and add a redirect.
|
||||||
|
|
||||||
|
## Frontmatter
|
||||||
|
|
||||||
|
Fumadocs' core frontmatter (`title`, `description`, `icon`) matches Blume — but **convert `icon` casing** (see Icons). Drops: **`full`** (Fumadocs' full-width/no-TOC layout — no equivalent; report) and **`_openapi`** (generated API stubs — delete the whole page, see OpenAPI). Any other non-schema key is a build error, so drop and report.
|
||||||
|
|
||||||
|
## Icons: lucide-react names → kebab-case
|
||||||
|
|
||||||
|
Fumadocs icons are strings resolved by the repo's own `icon` handler in `loader()` — in practice **PascalCase lucide-react export names** (`"BookOpen"`, `"HomeIcon"`). Blume resolves **kebab-case** Lucide names and silently renders nothing on a miss, so pass-through loses every icon. Read the loader's icon handler to confirm the scheme, then convert each name: strip any `Icon` suffix, kebab-case it (`BookOpen` → `book-open`, `HomeIcon` → `home`), and verify it exists at [lucide.dev/icons](https://lucide.dev/icons).
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- **Callouts:** `<Callout type="x">` → `:::` directive. Fumadocs types are `info` (the default), `warn`, `warning`, `error`, `success`, `idea`: `warn`/`warning`→`:::warning`, `error`→`:::danger`, `success`→`:::success`, `idea`→`:::tip`, `info` and **bare `<Callout>`** → `:::info`. `title` → `:::type[Title]`; drop `icon`.
|
||||||
|
- **Cards:** `<Cards>` → `<CardGroup>`. `<Card>` needs **prop surgery**, not pass-through: Fumadocs `icon` is a JSX element (`icon={<Cpu />}` imported from `lucide-react`) → Blume takes a string name (`icon="cpu"`); Fumadocs `description="…"` → Blume has no `description` prop — move the text into the card body.
|
||||||
|
- **Accordions (container/item inversion):** `<Accordions>` → `<Accordion>` (container); `<Accordion>` → `<AccordionItem>` (item).
|
||||||
|
- **File trees:** `<Files>` → **`<Tree>`** (the JSX container for `Tree.*` children — _not_ `<FileTree>`, which wraps a Markdown list); `<Folder>` → `<Tree.Folder>` (`defaultOpen` carries over); `<File>` → `<Tree.File>`. Or convert the whole block to a list-driven `<FileTree>`.
|
||||||
|
- **Tabs:** Fumadocs declares labels on the parent (`<Tabs items={['npm','pnpm']}>`) and selects with `<Tab value="npm">`. Blume's `<Tab>` carries its own `title`. Strip `items={[…]}` from `<Tabs>` and give each child `<Tab>` a `title` (from its `value`, or the positional `items` entry).
|
||||||
|
- **Pass through unchanged:** `<Steps>`/`<Step>`, `<TypeTable>` (Blume's was modeled on Fumadocs' — identical `type` record shape), `<GithubInfo>`.
|
||||||
|
- **`<Banner>`** (layout-mounted, not per-page) → the `banner` config field (`{ content, link, dismissible, id }`).
|
||||||
|
- **`<include>./partial.mdx</include>`** — Blume has no runtime include. **Inline** the partial's body (strip its frontmatter) at migration time; resolve nested includes recursively.
|
||||||
|
- **No equivalent — report:** `<DynamicCodeBlock>`, `<ImageZoom>` (Blume zooms content images by default), `<InlineTOC>`.
|
||||||
|
- **Strip or convert every import** — not just `fumadocs-*`: `lucide-react` imports (icon JSX → string names), `next/image`/`next/link` (→ Markdown image/link), and local components. **Inventory `mdx-components.tsx` before deleting it** — components registered there are used import-free in MDX bodies; port or inline each usage first.
|
||||||
|
|
||||||
|
## Code fences
|
||||||
|
|
||||||
|
- ` ```npm ` fences (Fumadocs' remark-npm accepts both) → ` ```package-install `.
|
||||||
|
- Adjacent fences sharing `tab="…"` meta render as grouped tabs in Fumadocs → wrap them in a Blume `<CodeGroup>` and drop the `tab=` attrs.
|
||||||
|
|
||||||
|
## OpenAPI
|
||||||
|
|
||||||
|
`fumadocs-openapi` writes **generated MDX stubs into the content tree** (`generateFiles()` output: pages containing `<APIPage>`/`<OpenAPIPage>` with `_openapi` frontmatter), plus `lib/openapi.ts` (`createOpenAPI`) and a generate script. Treat these exactly like Mintlify endpoint stubs: **delete the generated pages**, point `openapi: { enabled: true, sources: [{ spec }] }` at the spec (vendor it locally), add a `navigation.tabs` entry for the reference route, and remove `fumadocs-openapi`, `lib/openapi.ts`, and the generate script. Keep hand-written conceptual pages (intro/auth) under the reference route.
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
A `loader({ i18n })` setup (locale-suffixed files or locale dirs) → Blume `i18n: { defaultLocale, locales: [{ code, label }] }`. Locale **directories** match Blume's `dir` parser as-is; locale **file suffixes** (`page.cn.mdx`) need restructuring into locale folders. Report whichever transform you apply.
|
||||||
|
|
||||||
|
## Package.json & teardown
|
||||||
|
|
||||||
|
Repoint scripts (`dev`→`blume dev`, `build`→`blume build`, `start`→`blume preview`), remove the `fumadocs-*` deps and the host framework's deps (`next`, `react-router`, `@tanstack/*`…), add `blume`. Safe to delete after harvesting (see above for what to read first): `source.config.*`, `mdx-components.tsx`, the app/route dir, and host-framework config (`next.config.*`, `next-env.d.ts`, the `next` tsconfig plugin — or the React Router/TanStack equivalents).
|
||||||
|
|
||||||
|
## Dropped — report these
|
||||||
|
|
||||||
|
Folder `description` and `collapsible: false`; reversed rest (`z...a`) ordering; the extract (`...folder`) flatten semantics; deep-folder sidebar links; frontmatter `full`; `<DynamicCodeBlock>`/`<InlineTOC>`; custom Shiki transformers; any icon with no Lucide equivalent after casing conversion.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Mintlify → Blume
|
||||||
|
|
||||||
|
The deepest migration. Mintlify declares its **entire navigation in `docs.json`**; Blume derives navigation from the filesystem. The bulk of the work is reshaping content into folders + tabs and translating config, callouts, and icons.
|
||||||
|
|
||||||
|
## Detect
|
||||||
|
|
||||||
|
- `docs.json` (current) or `mint.json` (legacy) at the repo root — the config file.
|
||||||
|
- A `mintlify` dependency / `mintlify dev` script.
|
||||||
|
- Content is `.mdx` under the repo root (Mintlify has no `content.root` — pages live at the top level), with a `/snippets` folder and image dirs like `/images`.
|
||||||
|
|
||||||
|
Read `docs.json` first; it drives everything.
|
||||||
|
|
||||||
|
## Config: `docs.json`/`mint.json` → `blume.config.ts`
|
||||||
|
|
||||||
|
Resolve `$ref` includes first (Mintlify splits config across files). Map only what's set:
|
||||||
|
|
||||||
|
| Mintlify | Blume | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` / `title` | `title` | |
|
||||||
|
| `description` | `description` | |
|
||||||
|
| `logo` (string or `{ light, dark, href }`) | `logo` (string, or `{ image: { light, dark, alt }, text, href }`) | Mintlify's `light`/`dark` nest under Blume's `image`; ensure files land in `public/`. **If the SVG is local and monochrome** (a solid black or white mark), rewrite its `fill`/`stroke` to `currentColor` and collapse `{ light, dark }` to the string shorthand `logo: "/logo.svg"` — one file that inherits the theme text color and flips with light/dark automatically. If the logo is a **wordmark** (brand name baked in), set `text: ""` so it doesn't render twice beside `title` |
|
||||||
|
| `favicon` (string **or `{ light, dark }`**) | **drop the field** — copy **one** file into `public/` under the conventional name (`favicon.svg`/`icon.png`) | Blume auto-detects by filename; there is **no** favicon config field, and **no** light/dark favicon — a `{ light, dark }` source collapses to one; report the loss |
|
||||||
|
| `colors.primary` + `colors.light` | `theme.accent` (`{ light, dark }`) | accent is now **per-mode**: `accent: { light: <colors.primary>, dark: <colors.light> }`. If Mintlify sets only `primary`, collapse to the string shorthand `accent: "<colors.primary>"` (applies to both modes) |
|
||||||
|
| `colors.dark` | `theme.action` | |
|
||||||
|
| `appearance.default` | `theme.mode` (`light`/`dark`/`system`) | |
|
||||||
|
| `appearance.strict` | **drop** | `theme.strict` was removed — writing it is a config error |
|
||||||
|
| `background.color.{light,dark}` | `theme.background` (`{ light, dark }`) | one field now: `background: { light: …, dark: … }` (a bare string applies to both) |
|
||||||
|
| `background.image` | `theme.backgroundImage` (`{ light, dark }`) | same per-mode shape as `background` |
|
||||||
|
| `background.decoration` | **drop** | no Blume equivalent |
|
||||||
|
| `fonts.family` / `fonts.{heading,body}.family` | `theme.fonts.{display,body}` | only if the family is a curated Google-font slug (kebab-case, e.g. `space-grotesk`); otherwise drop and tell the user to add `@font-face` in `theme.css` |
|
||||||
|
| `banner` | `banner` (`{ content, dismissible, id, link }`) | **only** those keys — drop `banner.color`/`banner.type` |
|
||||||
|
| `styling.latex: true` | **drop the field** — block math `$$…$$` renders in `.mdx` with no config | there is **no** `markdown.math` field; inline `$…$` is **not** supported — convert inline math to `$$…$$` or drop it (report) |
|
||||||
|
| `styling.codeblocks.theme` | `markdown.codeBlocks.theme` (`{ light, dark }`) | |
|
||||||
|
| `search.prompt` | **drop** | no equivalent |
|
||||||
|
| `seo.metatags` | **drop** | no equivalent; use per-page `seo` frontmatter |
|
||||||
|
| `seo.indexing: "all"` | `search.indexing.includeHiddenPages: true` | |
|
||||||
|
| `variables` (`{{name}}`) | **inline into content** | Blume has no runtime `{{var}}` substitution — replace each `{{name}}` with its value in the pages |
|
||||||
|
| `integrations.posthog` (`{ apiKey, apiHost }`) | `analytics.posthog` (`{ key, host }`) | preserve the host verbatim (e.g. `us.posthog.com` — Blume's default is `us.i.posthog.com`) |
|
||||||
|
| `integrations` (GA, Plausible, Fathom, …) | `analytics.scripts` / `analytics.vercel` | one `scripts[]` entry per provider (`{ src, strategy, attributes }`); no first-class mapping beyond PostHog/Vercel |
|
||||||
|
| `contextual` (`["copy","chatgpt","claude",…]`) | **mostly free** | Copy-as-Markdown and Open-in-chat are default page actions; `mcp` needs `ai.mcp.enabled` + server output (report as a follow-up) |
|
||||||
|
| `redirects` | `redirects: [{ from, to }]` | static only — see below |
|
||||||
|
| `navigation.languages` | `i18n` | see i18n below |
|
||||||
|
|
||||||
|
A minimal result is often just `defineConfig({ title, logo, theme: { accent } })`.
|
||||||
|
|
||||||
|
## Icons: FontAwesome → Lucide (required)
|
||||||
|
|
||||||
|
Mintlify defaults to **FontAwesome**; Blume is **Lucide-only**. Convert every icon reference — frontmatter `icon`, nav-group `icon`, `<Icon>`, `<Card icon>` — to the closest Lucide name. Discard Mintlify's `iconType` (solid/regular/brands) entirely.
|
||||||
|
|
||||||
|
**Automate the frontmatter pass — don't hand-edit it.** `scripts/mintlify-codemod.mjs` (in this skill, zero-dependency) remaps every **frontmatter** `icon:` using the table below, drops brand/no-equivalent icons, and — in the same pass — drops/renames unsupported frontmatter keys (see [Frontmatter](#frontmatter)). It's deterministic and idempotent, and reports what it changed per file plus what it couldn't (unknown icons, OpenAPI-stub flags):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# dry run first (report only), then apply:
|
||||||
|
node <this-skill>/scripts/mintlify-codemod.mjs <content-dir>
|
||||||
|
node <this-skill>/scripts/mintlify-codemod.mjs --write <content-dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
The codemod touches **only frontmatter**. Icons in MDX **body** (`<Icon icon="…">`, `<Card icon="…">`, nav-group icons in `docs.json`) it does not see — convert those by hand using the same table. Common mappings:
|
||||||
|
|
||||||
|
| FontAwesome | Lucide | | FontAwesome | Lucide |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `bolt` | `zap` | | `gear`/`cog` | `settings` |
|
||||||
|
| `circle-info`/`info` | `info` | | `wand-magic-sparkles`/`magic` | `sparkles` |
|
||||||
|
| `house` | `house` | | `magnifying-glass` | `search` |
|
||||||
|
| `gauge`/`gauge-high` | `gauge` | | `puzzle-piece` | `puzzle` |
|
||||||
|
| `envelope` | `mail` | | `file-lines` | `file-text` |
|
||||||
|
| `pen-to-square` | `square-pen` | | `trash-can` | `trash-2` |
|
||||||
|
| `xmark`/`times` | `x` | | `circle-check` | `circle-check` |
|
||||||
|
| `triangle-exclamation` | `triangle-alert` | | `circle-exclamation` | `circle-alert` |
|
||||||
|
| `arrow-right-from-bracket` | `log-out` | | `right-to-bracket` | `log-in` |
|
||||||
|
| `location-dot`/`map-marker` | `map-pin` | | `comments` | `messages-square` |
|
||||||
|
| `cube` | `box` | | `cubes`/`boxes` | `boxes` |
|
||||||
|
| `layer-group` | `layers` | | `diagram-project`/`sitemap` | `workflow`/`network` |
|
||||||
|
| `chart-line` | `chart-line` | | `chart-simple`/`chart-column` | `chart-column` |
|
||||||
|
| `flask` | `flask-conical` | | `robot` | `bot` |
|
||||||
|
| `screwdriver-wrench`/`toolbox` | `wrench` | | `circle-question`/`question` | `circle-help` |
|
||||||
|
| `life-ring` | `life-buoy` | | `shield-halved` | `shield` |
|
||||||
|
| `rocket`/`book`/`book-open`/`code`/`terminal`/`key`/`lock`/`user`/`users`/`database`/`server`/`cloud`/`bell`/`calendar`/`star`/`heart`/`tag`/`folder`/`globe`/`link`/`download`/`upload`/`check`/`copy`/`play`/`filter` | _(same name — verify)_ |
|
||||||
|
|
||||||
|
**Rules:** verify each Lucide name exists at [lucide.dev/icons](https://lucide.dev/icons) before writing it. **Brand icons** (`fa6-brands:*` — github, discord, x, slack, linkedin…) mostly have **no** Lucide equivalent: for GitHub use the `github` config (renders the header repo link); for other socials, drop the icon and report it (or add via a Footer override after `blume eject`). Where no Lucide counterpart exists, **drop the icon and report it** — an unknown icon name is a build error.
|
||||||
|
|
||||||
|
## Navigation: `docs.json` `navigation` → filesystem + tabs
|
||||||
|
|
||||||
|
Mintlify's `navigation` object (`tabs`/`anchors`/`dropdowns`/`products`/`versions`/`languages`/`groups`/`pages`) is fully config-declared. **Prefer restructuring content into folders**, not porting the config verbatim:
|
||||||
|
|
||||||
|
- **`groups`** (`{ group, pages: [...] }`) → a folder per group. The `group` name → the folder's humanized name or a `meta.ts` `title`. Nested groups → nested folders. `expanded: false` → `meta.ts` `collapsed: true` (inverted). `tag` → the folder/page `sidebar.badge`. **Folder metadata is a `meta.ts` module** (`export default defineMeta({ title, icon, order, collapsed, pages })`), a TypeScript file — **not** JSON. Blume does not read a `meta.json`, so any folder-level config you carry over from the source (or hand-write to preserve a group's label/order/collapse) must be authored as `meta.ts`. (Render mode — flat/group/page — is **not** a `meta.ts` field; it's the global `navigation.sidebar.display` in `blume.config.ts`.)
|
||||||
|
- **Config-only nested groups don't exist on disk — you must materialize them, or they flatten silently.** A nested `{ group, pages }` almost never has a matching subfolder: its pages sit **flat in the parent directory** (e.g. `platform/analytics/getting-started.mdx`, `…/quick-reference.mdx`) and the grouping lives **only** in the `docs.json` `pages` array. If you leave the files where they are, filesystem-derived nav sees one flat folder and the inner group vanishes — Mintlify's `Analytics → Reference → {…}` becomes a flat `Analytics → {…}`. To preserve it you must **either** move those pages into a real subfolder (`platform/analytics/reference/`, with a `meta.ts` for the label/`collapsed`), **or** declare the shape in an explicit `navigation.sidebar`. Walk **every** `pages` array recursively and treat any nested `group` object as a folder-move to plan, not files already in place. When inventorying the nav (workflow step 2), record the config nesting depth separately from the on-disk depth — they diverge exactly here.
|
||||||
|
- **This is the canonical case for `navigation.sidebar` over folders.** Materializing a purely-presentational nested group as a subfolder changes URLs (`/platform/analytics/getting-started` → `/platform/analytics/reference/getting-started`) for no reason other than a visual grouping, forcing a `redirects` entry per page. When you want to keep the nesting **and** the URLs, an explicit `navigation.sidebar` group (`{ label, items }`) is the better trade — it nests the existing routes without moving any file. Pick per group; don't reflexively flatten.
|
||||||
|
- **`pages`** entries are page refs (paths without extension) → files at the corresponding path. An entry that's `"GET /path"` is an OpenAPI endpoint stub → **delete it** (Blume generates these; see OpenAPI).
|
||||||
|
- **`tabs`** (top-level `navigation.tabs`, e.g. `App` / `API` / `API reference`) → Blume's **header tabs** `navigation.tabs` (`{ label, path, icon? }`). **Keep them as tabs — do not flatten them into `navigation.sidebar` groups or sections.** Flattening is the easy translation error: it collapses the site's top-level structure into one global sidebar and loses the header tab bar entirely. A Mintlify top-level tab is a header tab; map it to one. Put each tab's pages in **one folder** and point the tab's `path` at it — the tab then scopes the sidebar automatically (under the tab's `path`, the sidebar shows only that folder). **Blume resolves the active tab by URL prefix** (the tab whose `path` is the longest prefix of the current route), so every page in a tab must live under that tab's single `path`. Mintlify tabs freely mix pages from any folder / arbitrary routes; Blume tabs scope by prefix, so a tab that pulls in routes from all over is **not portable as-is**. You have two choices, per tab:
|
||||||
|
- **Move the pages under one prefix** so they share the tab's `path` (a route change → add a `redirects` entry for each moved page). This preserves the exact tab grouping.
|
||||||
|
- **Accept the closest shape** — assign each shared/cross-cutting page to one tab's folder and link to it from the others — when moving routes isn't worth the churn.
|
||||||
|
|
||||||
|
Call this trade out explicitly in the migration report; don't silently pick one. **The scoping is bidirectional:** on the root/landing route Blume _hides_ every tab folder and lists only untabbed top-level pages — so a Mintlify home whose single sidebar showed everything becomes a lean root list plus one sidebar per tab. That's automatic; don't try to exclude tab folders from the root by hand.
|
||||||
|
|
||||||
|
- **`dropdowns`/`products`/`versions`** → `navigation.selectors` (`{ kind, label, items: [{ label, path, icon?, description?, tag? }] }`). Use `kind` `dropdown`/`product`/`version` accordingly.
|
||||||
|
- **`anchors` / `navigation.global.anchors` / `navbar.links`** (persistent header links — Blog, Changelog, Community, Contact/Support) → **`navigation.featured`** (`{ label, href, icon? }`). These pin to the **top of the sidebar, above every section, on every route** (not tab-scoped) — the right home for Mintlify's always-visible utility links. An external `href` opens in a new tab; an internal one (`/contact`) is build-time validated against your pages. Convert the FontAwesome `icon` to Lucide as usual. (This replaces the old "drop and report" for anchors.) The `navbar.primary` **CTA button** has no featured equivalent — see Dropped.
|
||||||
|
- **`languages`** → `i18n`, not a selector (see below).
|
||||||
|
- Only fall back to an explicit `navigation.sidebar` for a shape the filesystem genuinely can't express.
|
||||||
|
|
||||||
|
This reshaping **changes URLs** — a page moved from `getting-started/quickstart` into the API tab's folder becomes `/api/…`, an `index` promotion drops a segment, etc. Record each old→new path and add a `redirects` entry for it (see below); otherwise every existing link and bookmark 404s.
|
||||||
|
|
||||||
|
## Content & component transforms
|
||||||
|
|
||||||
|
Rewrite each page's MDX:
|
||||||
|
|
||||||
|
- **Callouts → directives** (directives are **MDX-only** — Mintlify content is already `.mdx`, so this just works; never rename a page to `.md`): `<Note>`→`:::note`, `<Tip>`→`:::tip`, `<Warning>`→`:::warning`, `<Info>`→`:::info`, `<Check>`→`:::success`, `<Danger>`/`<Error>`→`:::danger`. `<Callout type="x">` maps by type (`caution`→warning, `check`→success). A `title` attr → `:::type[Title]`. Drop `icon`/color props.
|
||||||
|
- **Accordions — container/item inversion!** Mintlify nests `<Accordion title="…">` inside `<AccordionGroup>`. Blume inverts: `<AccordionGroup>`→`<Accordion>` (container), and each Mintlify `<Accordion title="…">`→`<AccordionItem title="…">` (item).
|
||||||
|
- **`<RequestExample>`/`<ResponseExample>`** → `<CodeGroup>` (titled-fence tabs).
|
||||||
|
- **`<Tabs>` → `<Tabs inline>`.** Mintlify renders tabs **borderless** — a strip on a full-width rule with the content flowing beneath as prose — while Blume's `<Tabs>` defaults to a bordered box. Add `inline` to each `<Tabs>` to preserve Mintlify's appearance; every child `<Tab title="…">` is unchanged. Don't add `param` — Mintlify tabs switch in place and don't deep-link to the URL, so plain `inline` is the faithful mapping.
|
||||||
|
- **These pass through — Blume ships them natively:** `<Columns>`/`<Column>`, `<Expandable>`, `<Tooltip>`, `<Frame>`, `<Panel>`, `<Card>`/`<CardGroup>`, `<Tab>`, `<Steps>`/`<Step>`. Keep them as-is.
|
||||||
|
- **API fields → `TypeTable`.** Blume does **not** ship `<ParamField>`/`<ResponseField>`/`<RequestField>`. Convert a cluster of fields into one `<TypeTable>` (rows keyed by field name, each `{ type, required?, default?, description }`). For a fully spec'd API, prefer deleting the hand-written fields and using the [OpenAPI reference](#openapi) instead.
|
||||||
|
- **`<Update>`** (a Mintlify changelog entry) has no component form → convert to a `type: changelog` page, or use the `github-releases` source.
|
||||||
|
- **Snippets are inlined, not imported.** Blume has no `/snippets` import mechanism. For each `import X from "/snippets/x.mdx"` + `<X prop="v" />`, inline the snippet's body (substituting `{prop}` placeholders), then delete the import and the `/snippets` file. Named string imports (`import { foo } from "/snippets/vars.mdx"`) → inline the value at each `{foo}`.
|
||||||
|
|
||||||
|
## Frontmatter
|
||||||
|
|
||||||
|
Mintlify page frontmatter → Blume's strict schema. **`scripts/mintlify-codemod.mjs --write` does the mechanical rows automatically** (rename, drop, icon-remap) and flags the judgment rows (`openapi`/`asyncapi`/`api`) for you — see [Icons](#icons-fontawesome--lucide-required) for the invocation. The table is both the mapping reference and a description of exactly what the codemod does:
|
||||||
|
|
||||||
|
| Mintlify | Blume | Codemod |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `title` / `description` | pass through | — |
|
||||||
|
| `sidebarTitle` | `sidebar.label` | renames |
|
||||||
|
| `icon` | remapped to a Lucide name in place (top-level `icon` is valid Blume frontmatter; keep it there) | remaps |
|
||||||
|
| `tag` | `sidebar.badge` | renames |
|
||||||
|
| `canonical` | `seo.canonical` | renames |
|
||||||
|
| `og:image` | `seo.image` | renames |
|
||||||
|
| `hidden: true` | valid top-level in Blume — **kept as-is**; add `noindex: true` yourself if the page must also leave the search index | left (do by hand) |
|
||||||
|
| `openapi`/`asyncapi`/`api` | usually an endpoint stub → **delete the page** (Blume generates operation pages); else `type: api` | **flags** for review — never auto-deletes a page |
|
||||||
|
| `mode`, `public`, `rss`, `groups`, `keywords`, `hideApiMarker`, `hideFooterPagination`, `iconType` | **drop** (report) | drops |
|
||||||
|
|
||||||
|
The codemod leaves the source key in place and reports a conflict rather than clobbering data when a rename target already exists (e.g. a page already has `sidebar.label`) or the value is too structured to move safely — resolve those by hand. Remove any duplicate H1 in the body — `title` renders the H1. (The codemod only edits frontmatter; it never touches the body.)
|
||||||
|
|
||||||
|
## OpenAPI
|
||||||
|
|
||||||
|
Top-level `openapi`, `api.openapi`, or a per-group/per-tab `openapi` → `openapi: { enabled: true, sources: [{ spec, label?, route? }] }`. A Mintlify `{ source, directory }` object: `directory` → the source's `route`. **Delete every per-endpoint stub page** (frontmatter `openapi: "GET /path"` or a `"GET /path"` nav entry) — Blume's native renderer generates one real page per operation. **Add a `navigation.tabs` entry pointing at the reference `route` yourself** (Mintlify's API tab maps to it); the reference does not create a header tab automatically.
|
||||||
|
|
||||||
|
- **Vendor the spec.** Mintlify usually points at a spec **URL**. Copying that straight into `spec:` makes every build fetch it at build time — a single point of failure in CI/offline/behind a proxy, and a failed fetch silently drops the reference (leaving the tab pointing at a route that 404s). Prefer downloading it into the repo (`curl … -o openapi/<name>.json`) and pointing `spec` at that local path. If you keep the URL, report the dependency and consider a `prebuild` refresh-with-fallback.
|
||||||
|
- **Fix endpoint links.** Blume operation routes are `<route>/<slugified-tag>/<slugified-operationId>` (tag `Models` + id `listModels` → `/api-reference/models/listmodels`) — this differs from Mintlify's endpoint URLs, so **rewrite every inbound link to an operation**. `blume validate` resolves operation pages like any other route and flags the ones you miss.
|
||||||
|
- **Keep the "Introduction" page.** Mintlify commonly has a written intro/auth page in an "Introduction" group beside the "Endpoints" (openapi) group in the same tab. Keep it: a normal content page placed under the openapi `route` (e.g. `<root>/api-reference/introduction.mdx`) merges into the reference tab's sidebar alongside the generated operations. Delete only the per-endpoint stubs, not the conceptual pages.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
Mintlify serves every top-level dir (e.g. `/images`) at the site root. Blume serves `public/` at the site root. **Move root asset dirs into `public/`** (`mv images public/images`) — every `/images/...` reference still resolves, unchanged. Move loose root files (`logo.png`, `favicon.png`) under `public/` too.
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
`navigation.languages` (≥2) → `i18n: { defaultLocale, locales: [{ code, label }] }`. The `default: true` language → `defaultLocale`. Translated content already lives in ISO-code directories, which match Blume's `dir` parser — no file moves. Remove any language selector; language switching is handled by i18n.
|
||||||
|
|
||||||
|
## Dropped — report these
|
||||||
|
|
||||||
|
- **`navbar.primary`** (the prominent header **CTA button**, e.g. "Get Started"/"Sign Up") → no button equivalent; re-add via `navigation.tabs`, a `navigation.featured` link, or a Header override. (Plain `navbar.links` and `anchors` map to `navigation.featured` — see Navigation above, not here.)
|
||||||
|
- **`footer.socials`** → suggest the `github` config, or a Footer override.
|
||||||
|
- **Per-language banners** (`navigation.languages[].banner`) → no equivalent.
|
||||||
|
- **Dynamic redirects** (`:slug*`/`:id` params) → can't be static path-to-path; move to host rules (`_redirects`, `vercel.json`).
|
||||||
|
- **`<Update>`** changelog components, `iconType`, `background.decoration`, `search.prompt`, `seo.metatags`, non-curated fonts.
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Migrating into a real repo (monorepos, pnpm, Vercel)
|
||||||
|
|
||||||
|
The framework references (`mintlify.md`, etc.) cover translating _content and config_. This file covers the **host repo** the docs live in — the concerns that surface when the target isn't a bare docs folder but a pnpm/Turbo workspace deployed on Vercel. All six were real failures in a production migration; none are Blume bugs, they're integration steps.
|
||||||
|
|
||||||
|
**Golden rule for this whole file: detect, then edit surgically, then say what you changed and why.** Every edit here touches a file the repo owns (`pnpm-workspace.yaml`, `vercel.json`, `package.json`, the lockfile). Never guess a path depth or a workspace name — read it out of the repo first. Where a step can't be fully automated (the Astro patch, the Vercel Root Directory setting), leave a copy-pasteable manual instruction rather than a half-applied change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Non-standard content layout → scope `content.root` + `include`
|
||||||
|
|
||||||
|
`content.root` defaults to `"docs"`, and `blume` runs from the directory that holds `blume.config.ts` (in a monorepo, that's the docs _package_, e.g. `apps/docs/`). Many repos **don't** have a `docs/` subfolder — the content sits directly under the app dir in topic folders (`apps/docs/api/`, `apps/docs/getting-started/`). With the default root, Blume looks for `apps/docs/docs/` and finds nothing.
|
||||||
|
|
||||||
|
**Detect by finding where `.md`/`.mdx` actually live** — never assume `docs/`. From the docs package dir:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# top two path segments of every content file, deduped — your content folders
|
||||||
|
find . -path ./node_modules -prune -o \( -name '*.md' -o -name '*.mdx' \) -print \
|
||||||
|
| sed 's|^\./||' | cut -d/ -f1 | sort -u
|
||||||
|
```
|
||||||
|
|
||||||
|
If that lists a real `docs` folder, keep the default. If it lists content folders directly (`api`, `getting-started`, `guides`), the content root is the package dir itself.
|
||||||
|
|
||||||
|
**Set `content.root` to the package dir and scope with explicit `include` globs — do _not_ use `content.root: "."` alone.** A bare `.` root scans the whole package: `src/`, `public/`, `README.md`, config files, and (in a monorepo) anything a loose glob reaches. That's slow and pulls non-content Markdown into the site. Instead pin `root` and list the real content folders in `content.include`:
|
||||||
|
|
||||||
|
```ts blume.config.ts
|
||||||
|
import { defineConfig } from "blume";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
content: {
|
||||||
|
root: ".", // content sits directly under this package (apps/docs/), no docs/ subfolder
|
||||||
|
// include/exclude globs are relative to `content.root`. List the actual
|
||||||
|
// content folders so the scan never wanders into src/, public/, etc.
|
||||||
|
include: [
|
||||||
|
"api/**/*.{md,mdx}",
|
||||||
|
"getting-started/**/*.{md,mdx}",
|
||||||
|
"guides/**/*.{md,mdx}",
|
||||||
|
],
|
||||||
|
exclude: ["**/node_modules/**", "**/_*", "**/.*"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema field names, exactly: **`content.root`** (string, relative to the project/cwd), **`content.include`** (array of globs, relative to `content.root`, default `["**/*.{md,mdx}"]`), **`content.exclude`** (array of globs, relative to `content.root`, default `["**/_*", "**/.*"]`). Singular `include`/`exclude`, both arrays.
|
||||||
|
|
||||||
|
Report to the user: the detected content root, the folders you scoped `include` to, and anything Markdown you deliberately left out (a top-level `README.md`, a `CHANGELOG.md`) so they can confirm it isn't content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. pnpm `minimumReleaseAge` blocks installing fresh Blume
|
||||||
|
|
||||||
|
pnpm's supply-chain guard (`minimumReleaseAge`, often set to `1440` = 24h) refuses to install any package version published more recently than the window. Right after Blume publishes, that's **every** Blume release — so `pnpm add blume` / the workspace install fails or silently pins an older version.
|
||||||
|
|
||||||
|
**Do not disable the policy globally** — that removes the guard for the whole dependency tree. Add **only `blume`** to the exclude list.
|
||||||
|
|
||||||
|
**Detect** in `pnpm-workspace.yaml` (pnpm 10+, the primary location) and, for older setups, `.npmrc`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "minimumReleaseAge\|minimum-release-age" pnpm-workspace.yaml .npmrc 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix — `pnpm-workspace.yaml`** (surgical add; create the key if the policy is set but no exclude exists):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
minimumReleaseAge: 1440
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- blume
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix — `.npmrc`** form (if the policy lives there instead):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
minimum-release-age=1440
|
||||||
|
minimum-release-age-exclude[]=blume
|
||||||
|
```
|
||||||
|
|
||||||
|
Report: "Added `blume` to `minimumReleaseAgeExclude` so the just-published version installs; the release-age guard still applies to everything else."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Frozen lockfile — regenerate and commit `pnpm-lock.yaml` in the same change
|
||||||
|
|
||||||
|
CI and Vercel install with `--frozen-lockfile` (pnpm's default in CI): if `package.json` and `pnpm-lock.yaml` disagree by even one dependency, the install **fails immediately** — before any build step runs. A migration always edits dependencies (add `blume`, drop the old framework), so the lockfile is guaranteed stale.
|
||||||
|
|
||||||
|
**After every dependency edit, regenerate the lockfile and stage it with the manifest:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # NOT --frozen-lockfile — this rewrites pnpm-lock.yaml
|
||||||
|
git add package.json apps/docs/package.json pnpm-lock.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Run `pnpm install` (plain) from the **workspace root**, not the docs package, so the single root lockfile updates.
|
||||||
|
- The lockfile change belongs in the **same commit** as the `package.json` change. A commit that edits deps without the matching lockfile is a broken build for anyone who pulls it.
|
||||||
|
- Verify locally the way CI will: `pnpm install --frozen-lockfile` should succeed with no diff. If it errors "lockfile not up to date," you forgot the plain install.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vercel monorepo recipe (root-aware install + build)
|
||||||
|
|
||||||
|
A plain `blume build` works locally, but Vercel in a workspace needs three things aligned: the install must run at the **workspace root** (so pnpm resolves the whole graph under a frozen lockfile), the build must run in the **docs package**, and Vercel must be pointed at the built `dist/`. `blume build` emits a static site to `dist/` **relative to where it runs** — so building in `apps/docs/` produces `apps/docs/dist/`.
|
||||||
|
|
||||||
|
This is a **copyable template**, not prose. Three pieces:
|
||||||
|
|
||||||
|
**a) Docs package `apps/docs/package.json`** — the standard Blume scripts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev": "blume dev",
|
||||||
|
"build": "blume build",
|
||||||
|
"preview": "blume preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"blume": "^1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**b) `apps/docs/vercel.json`** — root-aware install, local build, `dist` output:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
|
"framework": null,
|
||||||
|
"installCommand": "cd ../.. && pnpm install --frozen-lockfile",
|
||||||
|
"buildCommand": "pnpm run build",
|
||||||
|
"outputDirectory": "dist"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**c) The one setting `vercel.json` can't hold — set it in the Vercel project (dashboard → Settings → General, or `vercel` CLI):**
|
||||||
|
|
||||||
|
- **Root Directory** = `apps/docs`. This makes install/build/output paths resolve from the docs package, which is what the `vercel.json` above assumes.
|
||||||
|
- **Node version** = 22 or newer (Blume requires it). Or pin it in the docs package: `"engines": { "node": ">=22" }`.
|
||||||
|
|
||||||
|
How the pieces fit, with Root Directory = `apps/docs`:
|
||||||
|
|
||||||
|
- `installCommand` does `cd ../..` to reach the **workspace root** and runs a frozen install of the whole graph. **Adjust `../..` to the app's actual depth** — an app at `packages/web/docs/` needs `cd ../../..`. Read the path; don't assume two levels.
|
||||||
|
- `buildCommand` runs in `apps/docs/` and invokes `blume build` (via the package's `build` script) → emits `apps/docs/dist/`.
|
||||||
|
- `outputDirectory: "dist"` is relative to the Root Directory (`apps/docs`), so it points at `apps/docs/dist/`.
|
||||||
|
- `framework: null` stops Vercel from auto-detecting Astro and overriding your build/output with its Astro preset (which expects a different output path). Blume's static output is plain files — Vercel just serves the folder.
|
||||||
|
|
||||||
|
If the repo uses **Turborepo**, you can instead build through the root pipeline — `"buildCommand": "cd ../.. && pnpm turbo build --filter=docs"` — for remote-cache reuse. Keep `outputDirectory: "dist"` and Root Directory `apps/docs`.
|
||||||
|
|
||||||
|
Report every piece you wrote and the two dashboard settings the user must set by hand (Root Directory, Node version) — those can't be committed.
|
||||||
|
|
||||||
|
**Redirect caveat on this deploy path:** a static build emits `vercel.json` redirect rules into `dist/`, but Vercel's **git-integration** builds read `vercel.json` only from the project's Root Directory — the copy inside `dist/` is honored only when the dist folder is deployed directly via the Vercel CLI. On the §4 setup, `blume.config.ts` redirects still work (Blume also emits per-route meta-refresh pages), but they're soft redirects, not real 3xx. If real 3xx responses matter (SEO for a large moved site), copy the generated redirect rules from `dist/vercel.json` after a build into the committed `apps/docs/vercel.json`'s `redirects` array, and note they must be re-synced when `redirects` change. (Most migrations are fine with the soft fallback — say which you chose.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Astro/Vite version mismatch → pnpm patch (manual)
|
||||||
|
|
||||||
|
Blume pins **`astro ^7`**. If the workspace forces a specific **Vite** version — a root `pnpm.overrides` / `resolutions` entry, or a `vite` in the workspace catalog — that pinned Vite can diverge from the version Astro 7 expects, and Astro's internal build behavior mismatches. The symptom is a **build-time crash inside Astro/Vite** (a Rollup/Vite internal error, a missing-export or hook-signature error deep in `astro`/`vite` during `blume build`) that doesn't reproduce in a clean, override-free project.
|
||||||
|
|
||||||
|
**Detect the risk factor** before it bites:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "\"vite\"\|vite@\| vite:" pnpm-workspace.yaml package.json 2>/dev/null | grep -i "override\|resolution\|catalog\|vite"
|
||||||
|
```
|
||||||
|
|
||||||
|
Blume is adding early version validation separately, so a future release will flag this at startup. Until then, **recognize the symptom and apply a pinned patch** — pnpm's `patch` workflow writes a committed diff that pnpm reapplies on every install (survives CI and frozen installs):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Open the offending version in an editable temp dir (use the exact version
|
||||||
|
# pnpm resolved — check `pnpm why astro`):
|
||||||
|
pnpm patch astro@7.0.6
|
||||||
|
|
||||||
|
# 2. Edit files in the printed temp path to reconcile the Astro/Vite interface
|
||||||
|
# (the exact edit depends on the mismatch; the crash's stack trace names the
|
||||||
|
# file and call site). Then commit the patch:
|
||||||
|
pnpm patch-commit '<the temp path pnpm printed>'
|
||||||
|
```
|
||||||
|
|
||||||
|
`patch-commit` writes `patches/astro@7.0.6.patch` and registers it under `patchedDependencies` in `pnpm-workspace.yaml` (pnpm 10+) or root `package.json` (`pnpm.patchedDependencies` on pnpm 9):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# pnpm-workspace.yaml
|
||||||
|
patchedDependencies:
|
||||||
|
astro@7.0.6: patches/astro@7.0.6.patch
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit **both** `patches/astro@7.0.6.patch` and the `patchedDependencies` entry, then re-run `pnpm install` and `blume build`.
|
||||||
|
|
||||||
|
This is a **manual step — do not fabricate the patch contents.** Leave the user this exact recipe plus the crash's stack trace, and tell them: the cleaner long-term fix is to relax the workspace's Vite pin so Astro 7 resolves its own compatible Vite, and drop the patch once Blume ships version validation. Don't fail silently — if a build crashes inside Astro/Vite and a Vite override exists, name this as the likely cause.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ultracite / oxfmt formatting → oxfmt patch (ship the bundled patch)
|
||||||
|
|
||||||
|
If the repo uses (or the user wants to adopt) **[Ultracite](https://www.ultracite.ai)** for formatting — an `ultracite check` / `ultracite fix` script, an `ultracite` dev dep, or oxlint/oxfmt in the toolchain — it will format the migrated `.md`/`.mdx` **and break Blume's `:::` directives.** Ultracite's formatter is **oxfmt**, and under `proseWrap` oxfmt joins the opening/closing `:::` fence line into the surrounding prose, which invalidates the directive (`:::note` … `:::` callouts, tabs, steps — the exact syntax you convert callouts _into_ in step 5). Blume's own repo hits this and pins a patched oxfmt; a migrated repo needs the same patch or every directive silently degrades to literal text on the next `ultracite fix`.
|
||||||
|
|
||||||
|
The fix is a committed **pnpm patch** (`patches/oxfmt@0.55.0.patch`), shipped with this skill at `assets/oxfmt@0.55.0.patch`. Unlike the Astro/Vite patch, this one is a **known, deterministic diff** — copy it in, don't regenerate it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Copy the shipped patch into the target repo's patches/ dir (keep the exact filename):
|
||||||
|
mkdir -p patches
|
||||||
|
cp "<skill>/assets/oxfmt@0.55.0.patch" patches/oxfmt@0.55.0.patch
|
||||||
|
```
|
||||||
|
|
||||||
|
Then register it under `patchedDependencies` — `pnpm-workspace.yaml` on pnpm 10+, or root `package.json` (`pnpm.patchedDependencies`) on pnpm 9:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# pnpm-workspace.yaml
|
||||||
|
patchedDependencies:
|
||||||
|
oxfmt@0.55.0: patches/oxfmt@0.55.0.patch
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit **both** the patch file and the `patchedDependencies` entry, then re-run `pnpm install`.
|
||||||
|
|
||||||
|
- **The patch is pinned to `oxfmt@0.55.0`** (the version Ultracite 7.8.x resolves). pnpm requires an exact version match — if `pnpm why oxfmt` reports a different version, the patch won't apply. Bump the key to the resolved version (the hunk is a one-line prose-wrap guard and usually still applies cleanly); if it doesn't, tell the user and fall back to keeping directive-heavy files out of the formatter's globs.
|
||||||
|
- **Not on pnpm?** The patch mechanism is pnpm-specific. For npm/yarn, either pin oxfmt and apply the diff with `patch-package`, or exclude `.md`/`.mdx` from Ultracite formatting so it never touches the directives — report whichever you chose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist for a workspace migration
|
||||||
|
|
||||||
|
- [ ] Located real content folders (not assumed `docs/`); set `content.root` + scoped `content.include`.
|
||||||
|
- [ ] `minimumReleaseAge` present? Added `blume` to `minimumReleaseAgeExclude` only.
|
||||||
|
- [ ] Ran plain `pnpm install`; committed `pnpm-lock.yaml` with the `package.json` change; verified `pnpm install --frozen-lockfile` is clean.
|
||||||
|
- [ ] Wrote `apps/docs/vercel.json` + package scripts; told the user to set Root Directory + Node 22 in the Vercel project.
|
||||||
|
- [ ] Checked for a workspace Vite override; if the build crashes inside Astro/Vite, gave the pnpm-patch recipe.
|
||||||
|
- [ ] Uses Ultracite/oxfmt? Shipped `patches/oxfmt@0.55.0.patch` + registered it under `patchedDependencies` so formatting doesn't mangle `:::` directives.
|
||||||
|
- [ ] Reported every repo-specific edit and every manual step left to the user.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Nextra → Blume
|
||||||
|
|
||||||
|
Nextra (on Next.js) declares navigation and per-page labels in `_meta` files — and crucially, **a folder's title and its pages' sidebar labels live in the _parent_ `_meta`**. Reconciling that cross-file inheritance is the main task. Nextra 4 was a near-rewrite, so **fingerprint the generation first** — it decides where config lives, where content lives, and what the components are called.
|
||||||
|
|
||||||
|
## Detect & fingerprint the generation
|
||||||
|
|
||||||
|
- **`_meta.{js,mjs,cjs,ts,jsx,tsx,json}`** files — the strongest fingerprint (v4 also has a single-file `_meta.global.*` variant).
|
||||||
|
- **v2/v3 (Pages Router):** content in `pages/`, `_meta.json` (v2) or `_meta.{js,ts}` (v3), a `theme.config.{js,jsx,ts,tsx}`, components from `nextra-theme-docs` (v2) or `nextra/components` (v3).
|
||||||
|
- **v4 (App Router):** **no `theme.config`** — config lives as component props in `app/layout.{jsx,tsx}` (`<Layout>`, `<Navbar>`, `<Banner>`, `<Footer>`, `<Search>`). Content is either a **`content/`** directory (with a catch-all `app/[[...mdxPath]]/page.jsx` gateway) **or** Next page files: **`app/**/page.mdx`**.
|
||||||
|
- `nextra` + `next` deps; `next.config.*` wrapping `nextra({…})`.
|
||||||
|
|
||||||
|
**v4 app-directory repos need a restructure, not a `content.root`.** Pointing `content.root` at `app/` gives every route a trailing `/page` and scans `layout.jsx`/gateway files. Move each `app/foo/page.mdx` → `foo.mdx` (or `foo/index.mdx` when it has children), and exclude the non-MDX app files. The catch-all `page.jsx`, `mdx-components.*`, and `app/layout.*` are teardown artifacts — but **harvest `app/layout.*` first** (see Config).
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
None of it maps automatically — read the config surface for the generation by hand and reconstruct in `blume.config.ts`:
|
||||||
|
|
||||||
|
- **v2/v3 — `theme.config.*`:** `logo` (JSX — extract text/image) → `logo`; `project.link` → `github` (renders the header repo link); `docsRepositoryBase` → `github` (owner/repo/branch — and any trailing sub-path → `github.dir`); `banner.text`/`banner.key` → `banner.content`/`banner.id` (`dismissible` maps); `primaryHue`/`primarySaturation` → pick an equivalent `theme.accent` color; `footer` → drop (report); `faviconGlyph` → drop (Blume's favicon is a file convention); `useNextSeoProps`/head → per-page `seo` frontmatter or drop.
|
||||||
|
- **v4 — `app/layout.*` props:** the same facts moved: `<Navbar logo projectLink>`, `<Layout docsRepositoryBase editLink sidebar={{…}} toc={{…}}>`, `<Banner>`, `<Footer>`. Map them the same way; sidebar/TOC tuning mostly drops (Blume's `toc` config covers min/max heading levels).
|
||||||
|
- **`next.config.*`:** harvest **`redirects()`** — static entries become Blume `redirects: [{ from, to }]`; wildcard/dynamic ones move to host config (report). A `latex: true` flag means math is in play (see Math below). Then delete the file.
|
||||||
|
- Root `_meta` entries with **`type: "page"`** → `navigation.tabs` (`{ label, path }`, where `path` is `/` for `index`, else `/<slug>`). `type: "page"` only maps at the **root**.
|
||||||
|
|
||||||
|
## Navigation: `_meta` → `meta.ts` + frontmatter
|
||||||
|
|
||||||
|
**Every `_meta` file must be carried over — always convert it to `meta.ts` + frontmatter, never drop it in favor of filename inference.** `_meta` is Nextra's canonical nav source (ordering, labels, folder titles); the filesystem alone can't reproduce it.
|
||||||
|
|
||||||
|
For each `_meta` entry (`key` = slug, value = string title or `{ title, type, display, href, theme }`):
|
||||||
|
|
||||||
|
| Nextra `_meta` entry | Blume |
|
||||||
|
| --- | --- |
|
||||||
|
| ordinary page/folder (string or `{ title }`) | slug → parent `meta.ts` `pages` (ordering). A **page** title → that page's frontmatter `sidebar.label`. A **folder** title → that child folder's `meta.ts` `title` (title lives in the parent!). A JSX title → extract its text. |
|
||||||
|
| `display: "hidden"` | frontmatter `sidebar.hidden: true` |
|
||||||
|
| `type: "separator"` | drop → recreate as a `(Group)/` folder / `meta.ts` boundary if needed |
|
||||||
|
| `type: "menu"` (navbar dropdown) | drop → recreate via `navigation.selectors` if wanted |
|
||||||
|
| `href` (external link) | **`navigation.featured`** (`{ label, href, icon? }` — pinned above the sidebar on every route); only drop deep-nested ones (report) |
|
||||||
|
| `type: "page"` (subfolder, not root) | drop (only root → tabs) |
|
||||||
|
| `theme: { collapsed }` on a folder | `meta.ts` `collapsed` |
|
||||||
|
| `theme: { layout: "full" \| sidebar: false \| … }` | drop (report — no per-page layout switches) |
|
||||||
|
| `"*"` wildcard entry | apply its value as the default for unlisted siblings, then drop |
|
||||||
|
|
||||||
|
Write each folder's `meta.ts` with its `pages` order (from its own `_meta`) and its `title` (inherited from the parent's `_meta`). A v4 **`_meta.global.*`** file holds the whole tree in one place (folder entries carry `items`) — split it into per-folder `meta.ts` files with the same rules.
|
||||||
|
|
||||||
|
## Frontmatter & titles — synthesize, don't just pass through
|
||||||
|
|
||||||
|
Most Nextra pages have **no frontmatter**; the title falls back `_meta` title → `sidebarTitle` → `title` → **first body H1** → filename. Blume renders frontmatter `title` as the page H1, so for **every page**: set frontmatter `title` (from the `_meta` title, else the body H1, else a humanized filename), **remove the body H1**, and map `sidebarTitle` → `sidebar.label`. `asIndexPage: true` (v4 folder-index marker) → make the file the folder's `index.mdx` and drop the key. Drop any other non-schema key and report it.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- **Callouts:** `<Callout type="x">` → directive. Types: `default` (the prop default) → `:::note`, `info`→`:::info`, `warning`→`:::warning`, `error`→`:::danger`, `important` (v4) → `:::note`. Bare `<Callout>` → `:::note`. Drop `emoji` (there is no `title` prop to carry — Nextra callouts have none).
|
||||||
|
- **v2 flat names:** v2 exported `Tab` and `Card` (flat) from **`nextra-theme-docs`**; v3/v4 use `Tabs.Tab`/`Cards.Card` from `nextra/components`. Handle both: `<Cards>`/`<Cards.Card>`/`<Card>` → `<CardGroup>`/`<Card>`; `<Tabs items={[…]}>` + `<Tabs.Tab>`/`<Tab>` → `<Tabs>`/`<Tab title="…">` (move labels from the parent `items` onto each `<Tab>`). `Cards` `num` → `CardGroup` `cols`.
|
||||||
|
- **`<Steps>`:** Nextra's wraps **Markdown headings (h2–h6)**, one step per heading. Convert each heading to a `<Step title="…">` child (Blume `<Steps>`/`<Step>`) and delete the heading — this also keeps step titles out of the TOC, matching Nextra's behavior.
|
||||||
|
- **`<FileTree>`/`<FileTree.Folder>`/`<FileTree.File>`** → `<Tree>`/`<Tree.Folder>`/`<Tree.File>` (or a list-driven `<FileTree>`).
|
||||||
|
- **`<Bleed>`** (full-bleed) → no equivalent; drop the wrapper and report. **`<Table>`** → a plain Markdown table. **`<Banner>`** (v4, in layout) → the `banner` config.
|
||||||
|
- **GitHub alert blockquotes** (v4 renders them): `> [!NOTE]`/`[!TIP]`/`[!WARNING]`/`[!IMPORTANT]`/`[!CAUTION]` → `:::note`/`:::tip`/`:::warning`/`:::note`/`:::warning` directives — Blume renders them as plain blockquotes otherwise.
|
||||||
|
- **Strip or convert every import:** `nextra`, `nextra/*`, `nextra-theme-docs`, `nextra-theme-blog`, plus `next/image` (→ Markdown image or `<Frame>`), `next/link` (→ plain link), and local components (port or inline; report).
|
||||||
|
|
||||||
|
## Code fences
|
||||||
|
|
||||||
|
Nextra's fence meta differs from Blume's — rewrite it: `filename="app.js"` → a space-separated title (` ```js app.js `); `showLineNumbers` → `lineNumbers`; line highlighting `{1,4-5}` carries over unchanged; **drop** word-highlight `/word/`, `copy`/`copy=false`, and inline-code `{:lang}` suffixes. ` ```sh npm2yarn ` fences → ` ```package-install `.
|
||||||
|
|
||||||
|
## Math
|
||||||
|
|
||||||
|
Nextra enables math via `nextra({ latex: true })` (KaTeX or MathJax). In Blume, block math `$$…$$` renders in `.mdx` with **no config** (there is no `markdown.math` field). Convert Nextra's ` ```math ` fences → `$$…$$` blocks (they render as plain code blocks otherwise). Inline `$…$` is **not** supported — convert to display math or drop (report). MathJax-specific macros → report.
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
- **v2/v3:** locale **file suffixes** (`index.en.mdx`, `index.zh.mdx`) + `i18n` in `next.config` → restructure into locale **folders** (default locale at the content root, others under `<code>/`), then `i18n: { defaultLocale, locales: [{ code, label }] }`.
|
||||||
|
- **v4:** `content/<lang>/` dirs already match Blume's `dir` parser — map the locale list, no file moves.
|
||||||
|
|
||||||
|
## Package.json & teardown
|
||||||
|
|
||||||
|
Repoint `dev`/`build`/`start` scripts to the Blume CLI; remove `next`/`nextra`/`nextra-theme-*` deps and add `blume`. Delete after harvesting: `next.config.*` (redirects first!), `theme.config.*` (v2/v3), `app/layout.*` + the catch-all gateway + `mdx-components.*` (v4).
|
||||||
|
|
||||||
|
## Dropped — report these
|
||||||
|
|
||||||
|
Footer content; `primaryHue`-style theming beyond an accent color; `faviconGlyph`; `_meta` separators, menus, `newWindow`; per-page `theme` layout switches; `<Bleed>`; MathJax macros; word-highlight/copy fence meta; any icon you can't reconstruct as a Lucide name.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Starlight → Blume
|
||||||
|
|
||||||
|
Starlight is an Astro integration configured via `starlight({…})` in `astro.config.*`, with content in `src/content/docs`. Both are Astro, so this is a natural fit — content can mostly stay in place, with one big caveat: **most Starlight content is `.md`, and Blume's `:::` directives are MDX-only** (see Asides below).
|
||||||
|
|
||||||
|
## Detect
|
||||||
|
|
||||||
|
- `astro.config.{mjs,mts,ts,js,cjs}` importing and calling **`starlight({…})`** (`import starlight from "@astrojs/starlight"`).
|
||||||
|
- Content under **`src/content/docs/`**; a `src/content.config.ts` (current) or `src/content/config.ts` (pre-Astro-5).
|
||||||
|
- `@astrojs/starlight` dep.
|
||||||
|
|
||||||
|
Keep content where it is — set `content.root: "src/content/docs"`.
|
||||||
|
|
||||||
|
## Config: `starlight({…})` → `blume.config.ts`
|
||||||
|
|
||||||
|
**Harvest the surrounding `astro.config.*` too, not just the `starlight()` call:** top-level Astro `redirects` → Blume `redirects`; `site` → leave unset (Blume auto-detects); other integrations → report.
|
||||||
|
|
||||||
|
| Starlight option | Blume |
|
||||||
|
| --- | --- |
|
||||||
|
| `title` | `title` (if per-locale/computed, set manually) |
|
||||||
|
| `description` | `description` |
|
||||||
|
| `logo` (`{ src }` or `{ light, dark, alt }`) | `logo` — move the referenced file into `public/` |
|
||||||
|
| `logo.replacesTitle` | `logo: { text: "" }` (renders the mark alone) |
|
||||||
|
| `favicon` | copy the file into `public/` (drop the config field — Blume auto-detects) |
|
||||||
|
| `social` (array of `{ label, icon, href }`; pre-0.33 legacy: `{ github: url }` object) | derive **`github: { owner, repo }`** from the GitHub entry; other socials drop (report) |
|
||||||
|
| `editLink.baseUrl` (`…/edit/<branch>/<subdir?>`) | `github: { owner, repo, branch }` — **and any repo sub-path after the branch → `github.dir`** (a docs-in-subfolder repo breaks every edit link without it) |
|
||||||
|
| `sidebar` (array) | filesystem nav / `navigation.sidebar` (see below) |
|
||||||
|
| `tableOfContents` (`false` or `{ minHeadingLevel, maxHeadingLevel }`) | `toc` — identical shape, 1:1 |
|
||||||
|
| `markdown.headingLinks: false` | `markdown.headingAnchors: false` |
|
||||||
|
| `expressiveCode.themes` | `markdown.codeBlocks.theme: { light, dark }` — EC assigns by theme _type_, so match each theme by its darkness, not by array position |
|
||||||
|
| `expressiveCode.styleOverrides` | drop → restyle via a root `theme.css` file |
|
||||||
|
| `head` | **drop and report** (there is no `seo.metatags`); analytics `<script>` entries → the `analytics` config |
|
||||||
|
| `lastUpdated: true` | `lastModified: true` |
|
||||||
|
| `customCss` | drop → move into a root **`theme.css`** file (auto-picked-up; not a config field) |
|
||||||
|
| `components` (overrides) | Blume's layout slots via `defineComponents({ layout: { Header, Search, Sidebar, TableOfContents, Footer, … } })` — a near-1:1 map; reach for `blume eject` only beyond those |
|
||||||
|
| `plugins` | **map, don't blanket-drop** — see Plugins below |
|
||||||
|
| `pagination: false`, `pagefind`/Pagefind options, `titleDelimiter`, `credits`, `disable404Route`, `routeMiddleware` | drop (report) |
|
||||||
|
| `locales` / `defaultLocale` | `i18n` (see below) |
|
||||||
|
|
||||||
|
## Navigation: `sidebar`
|
||||||
|
|
||||||
|
Starlight's `sidebar` array → prefer letting Blume generate from the filesystem; use `navigation.sidebar` only for shapes files can't express:
|
||||||
|
|
||||||
|
- `{ label, autogenerate: { directory: "d" } }` → **structure the folder and rely on filesystem nav** (label → the folder's `meta.ts` `title`). Do **not** map it to a sidebar item with `root:` — in an explicit Blume sidebar that renders a single page link, not the directory's children.
|
||||||
|
- string `"guides/intro"` → `"/guides/intro"`; `{ label?, slug }` → `/<slug>`.
|
||||||
|
- `{ label, items: [...] }` → `{ label, items: [...] }` (recursive) — or better, a real folder.
|
||||||
|
- `{ label?, link }` → `{ label, href: link }`.
|
||||||
|
- `badge` (string or `{ text, variant }`) → `badge` (text only; variant drops). `collapsed` (bool) → `collapsed`. Item `attrs` and `translations` → drop (report).
|
||||||
|
|
||||||
|
## Frontmatter
|
||||||
|
|
||||||
|
| Starlight | Blume |
|
||||||
|
| --- | --- |
|
||||||
|
| `title` / `description` / `draft` / `slug` | pass through |
|
||||||
|
| `sidebar.{label,order,hidden}` | pass through |
|
||||||
|
| `sidebar.badge` | `sidebar.badge` (text only) |
|
||||||
|
| `sidebar.attrs` | **remove** (strict schema — build error if left) |
|
||||||
|
| `pagefind: false` | `search.exclude: true` |
|
||||||
|
| `lastUpdated` (date/string) | `lastModified` |
|
||||||
|
| `lastUpdated: false` (boolean) | **remove** (Blume's `lastModified` takes a date; a boolean is a build error) |
|
||||||
|
| `prev` / `next` (booleans or labels) | **drop** (report — no per-page pagination toggle; `hideFooterPagination` does not exist) |
|
||||||
|
| `template: splash` / `hero` | **no equivalent** — rebuild as a custom `.astro` page under `content.pages`; a `hero` on a normal page drops (report) |
|
||||||
|
| `banner`, `tableOfContents`, `editUrl`, `head` | drop (report) |
|
||||||
|
|
||||||
|
## Asides → directives (and the `.md` trap)
|
||||||
|
|
||||||
|
Starlight's primary callout syntax is the `:::note`/`:::tip`/`:::caution`/`:::danger` directive **in plain `.md` files**. The directive names map perfectly (Blume aliases `caution`→warning; `error`→danger) and `[Title]` syntax carries over — **but Blume only parses directives in `.mdx`**. In a `.md` file, `:::note` renders as literal text and the build stays green. So: **rename every `.md` file that contains asides (or math, or mermaid/package-install fences) to `.mdx`** — for a typical Starlight repo that's most of the content; renaming everything to `.mdx` is usually simpler and safe.
|
||||||
|
|
||||||
|
- `<Aside type="…" title="…">` (the component form) → the same directives; bare `<Aside>` → `:::note`.
|
||||||
|
- An aside custom icon (`:::tip{icon="heart"}`) → drop the attr (report).
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- **Renames:** `<CardGrid>` → `<CardGroup>`; `<LinkCard>` → `<Card>` (its `description` prop drops — fold into the body); `<TabItem label="…">` → `<Tab title="…">`. `<Tabs>` and `<Card>` stay; a `<Tabs syncKey="…">` → strip the prop (Blume tabs sync by default).
|
||||||
|
- **`<Badge>` needs conversion, not pass-through:** Starlight puts content in a `text` prop and uses variants `note`/`tip`/`caution`/`danger`/`success`/`default` with sizes `small`/`medium`/`large`. Blume's `<Badge>` renders **children** with variants `default`/`accent`/`success`/`warning`/`danger` and sizes `xs`/`sm`/`md`/`lg`. Move `text` into the children; remap variant (`note`→`default`, `tip`→`accent`, `caution`→`warning`, `danger`→`danger`, `success`→`success`) and size (`small`→`sm`, `medium`→`md`, `large`→`lg`).
|
||||||
|
- **Convert yourself:** `<Steps>` → Blume `<Steps>`/`<Step>`; `<FileTree>` → Blume `<FileTree>`; `<Code code={…}>` → a fenced code block; `<LinkButton>` → a Markdown link or `<Card>`.
|
||||||
|
- Strip `import … from "@astrojs/starlight/*"` and `astro:assets` lines.
|
||||||
|
|
||||||
|
## Code blocks: Expressive Code meta → Blume
|
||||||
|
|
||||||
|
Starlight content is full of Expressive Code fence meta; Blume understands some of it and **promotes unknown bare tokens into the code-block title**, so unconverted meta produces garbage headers. Convert per fence:
|
||||||
|
|
||||||
|
- `title="file.js"` → works as-is (or use the space-title shorthand). Line ranges `{2-3}` → work as-is.
|
||||||
|
- `ins=`/`del=` line marks → `// [!code ++]` / `// [!code --]` comments; `mark=` → `{ranges}` or `// [!code highlight]`.
|
||||||
|
- `showLineNumbers` → `lineNumbers`.
|
||||||
|
- **Drop:** `frame="terminal"`, `collapse=`, `wrap`, `"string"` and `/regex/` text markers (report if they carried meaning).
|
||||||
|
- ` ```diff lang="js" ` → a normal ` ```js ` fence with `[!code ++]`/`[!code --]` markers.
|
||||||
|
|
||||||
|
## Plugins — map, don't drop
|
||||||
|
|
||||||
|
- `starlight-openapi` → Blume's native `openapi.sources` (delete any generated pages; add the `navigation.tabs` entry).
|
||||||
|
- `starlight-blog` → `type: blog` pages.
|
||||||
|
- `starlight-versions` → `navigation.selectors` with `kind: "version"`.
|
||||||
|
- `starlight-image-zoom` → delete (Blume zooms content images by default).
|
||||||
|
- `starlight-links-validator` → delete (`blume validate` covers it).
|
||||||
|
- Anything else → report.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
Starlight co-locates images in `src/assets/` with **relative** references (`../../assets/foo.png`) or `astro:assets` imports; Blume serves `public/` at the site root with absolute URLs. Move `src/assets/*` into `public/`, rewrite relative image paths and `~/`/`@/` aliases to absolute `/…` URLs, and replace `<Image>` imports with Markdown images (or `<Frame>`).
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
Starlight has **two** layouts:
|
||||||
|
|
||||||
|
- **`root` locale** (default language at `src/content/docs/`, others in `fr/`, `de/`… subdirs) → matches Blume's `dir` parser as-is: `i18n: { defaultLocale, locales: [{ code, label }] }`, no file moves.
|
||||||
|
- **No `root` locale** (every language in a subdir, including the default — `en/…`) → Blume expects the default locale **at the content root**, so move the default locale's files up one level. Starlight served them at `/en/…`, so add a `redirects` entry per page.
|
||||||
|
|
||||||
|
Don't restate Blume defaults (`hideDefaultLocalePrefix: true`, `parser: "dir"` are already the defaults). Starlight's untranslated-page fallback matches Blume's `fallbackLocale` default.
|
||||||
|
|
||||||
|
## Teardown
|
||||||
|
|
||||||
|
Remove `@astrojs/starlight` (and plugin deps) from deps, delete the Starlight bits of `astro.config.*` **after harvesting redirects**, delete `src/content.config.ts` / `src/content/config.ts`, repoint scripts to the Blume CLI, add `blume`. A `src/content/docs/404.md` has no direct Blume equivalent — report it (Blume ships its own 404).
|
||||||
|
|
||||||
|
## Dropped — report these
|
||||||
|
|
||||||
|
Non-GitHub socials, badge variants, sidebar/item `attrs` + `translations`, `customCss` beyond `theme.css`, `head` entries, `routeMiddleware`, splash/hero pages (rebuild as custom pages), aside custom icons, EC frames/collapse/text markers, prev/next toggles, unmapped plugins, any `<Icon>` name with no Lucide equivalent.
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// mintlify-codemod.mjs — deterministic, idempotent frontmatter codemod for the
|
||||||
|
// Mintlify → Blume migration. It rewrites ONLY the YAML frontmatter block of
|
||||||
|
// `.md`/`.mdx` files; the body is never touched. Two passes:
|
||||||
|
//
|
||||||
|
// 1. Icons — remap Mintlify (FontAwesome) `icon:` names to their closest
|
||||||
|
// Lucide equivalent (Blume is Lucide-only). Brand/no-equivalent
|
||||||
|
// icons are dropped and reported, never faked.
|
||||||
|
// 2. Fields — drop frontmatter keys Blume's strict schema rejects, and rename
|
||||||
|
// Mintlify-only keys to their Blume nesting (sidebarTitle →
|
||||||
|
// sidebar.label, tag → sidebar.badge, canonical → seo.canonical,
|
||||||
|
// og:image → seo.image). Ambiguous keys (openapi/asyncapi/api)
|
||||||
|
// are flagged for human review, not transformed.
|
||||||
|
//
|
||||||
|
// Design constraints:
|
||||||
|
// - ZERO dependencies — safe in a pnpm-strict workspace with no hoisting; runs
|
||||||
|
// with a bare `node`, needs nothing from the target repo's node_modules.
|
||||||
|
// - Deterministic — no Date/Math.random; files processed in sorted order.
|
||||||
|
// - Idempotent — running twice makes no further change. Source keys are gone
|
||||||
|
// after the first pass; remapped icon values are already Lucide (every
|
||||||
|
// mapped value that is also a source key maps to itself); a rename whose
|
||||||
|
// target already exists is reported, not re-applied.
|
||||||
|
// - Surgical — untouched lines keep their exact formatting; only the specific
|
||||||
|
// lines that change are edited, so diffs stay small and reviewable.
|
||||||
|
// - Reports every change per file (and every drop/flag), so nothing is silent.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node mintlify-codemod.mjs <path...> # dry run — report only
|
||||||
|
// node mintlify-codemod.mjs --write <path...> # apply changes in place
|
||||||
|
// node mintlify-codemod.mjs --json <path...> # machine-readable report
|
||||||
|
// node mintlify-codemod.mjs --help
|
||||||
|
//
|
||||||
|
// Paths may be files or directories; directories are walked for .md/.mdx.
|
||||||
|
|
||||||
|
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// --- Mintlify (FontAwesome) → Lucide icon map ------------------------------
|
||||||
|
// Keys are lowercased Mintlify icon names; values are Lucide names. A value of
|
||||||
|
// `null` means "no Lucide equivalent" (brand icons, mostly) — the icon line is
|
||||||
|
// removed and reported. Every value that is itself a key maps to the same name,
|
||||||
|
// which keeps the pass idempotent (a second run finds nothing to change). The
|
||||||
|
// FontAwesome `x` (close) maps to Lucide `x`; the X/Twitter brand is
|
||||||
|
// `x-twitter` and has no Lucide form.
|
||||||
|
const ICONS = {
|
||||||
|
angular: null,
|
||||||
|
apple: null,
|
||||||
|
"arrow-right-from-bracket": "log-out",
|
||||||
|
aws: null,
|
||||||
|
bell: "bell",
|
||||||
|
bolt: "zap",
|
||||||
|
book: "book",
|
||||||
|
"book-open": "book-open",
|
||||||
|
boxes: "boxes",
|
||||||
|
calendar: "calendar",
|
||||||
|
"chart-column": "chart-column",
|
||||||
|
"chart-line": "chart-line",
|
||||||
|
"chart-simple": "chart-column",
|
||||||
|
check: "check",
|
||||||
|
"circle-check": "circle-check",
|
||||||
|
"circle-exclamation": "circle-alert",
|
||||||
|
"circle-info": "info",
|
||||||
|
"circle-question": "circle-help",
|
||||||
|
cloud: "cloud",
|
||||||
|
code: "code",
|
||||||
|
cog: "settings",
|
||||||
|
comments: "messages-square",
|
||||||
|
copy: "copy",
|
||||||
|
cube: "box",
|
||||||
|
cubes: "boxes",
|
||||||
|
database: "database",
|
||||||
|
"diagram-project": "workflow",
|
||||||
|
discord: null,
|
||||||
|
docker: null,
|
||||||
|
download: "download",
|
||||||
|
envelope: "mail",
|
||||||
|
facebook: null,
|
||||||
|
"file-lines": "file-text",
|
||||||
|
filter: "filter",
|
||||||
|
flask: "flask-conical",
|
||||||
|
folder: "folder",
|
||||||
|
gauge: "gauge",
|
||||||
|
"gauge-high": "gauge",
|
||||||
|
gear: "settings",
|
||||||
|
github: null,
|
||||||
|
gitlab: null,
|
||||||
|
globe: "globe",
|
||||||
|
google: null,
|
||||||
|
heart: "heart",
|
||||||
|
house: "house",
|
||||||
|
info: "info",
|
||||||
|
instagram: null,
|
||||||
|
java: null,
|
||||||
|
js: null,
|
||||||
|
key: "key",
|
||||||
|
"layer-group": "layers",
|
||||||
|
layers: "layers",
|
||||||
|
"life-ring": "life-buoy",
|
||||||
|
link: "link",
|
||||||
|
linkedin: null,
|
||||||
|
"location-dot": "map-pin",
|
||||||
|
lock: "lock",
|
||||||
|
magic: "sparkles",
|
||||||
|
"magnifying-glass": "search",
|
||||||
|
"map-marker": "map-pin",
|
||||||
|
medium: null,
|
||||||
|
microsoft: null,
|
||||||
|
node: null,
|
||||||
|
"node-js": null,
|
||||||
|
npm: null,
|
||||||
|
"pen-to-square": "square-pen",
|
||||||
|
php: null,
|
||||||
|
play: "play",
|
||||||
|
puzzle: "puzzle",
|
||||||
|
"puzzle-piece": "puzzle",
|
||||||
|
python: null,
|
||||||
|
question: "circle-help",
|
||||||
|
react: null,
|
||||||
|
"right-to-bracket": "log-in",
|
||||||
|
robot: "bot",
|
||||||
|
rocket: "rocket",
|
||||||
|
rust: null,
|
||||||
|
"screwdriver-wrench": "wrench",
|
||||||
|
search: "search",
|
||||||
|
server: "server",
|
||||||
|
settings: "settings",
|
||||||
|
shield: "shield",
|
||||||
|
"shield-halved": "shield",
|
||||||
|
sitemap: "network",
|
||||||
|
slack: null,
|
||||||
|
sparkles: "sparkles",
|
||||||
|
star: "star",
|
||||||
|
stripe: null,
|
||||||
|
tag: "tag",
|
||||||
|
telegram: null,
|
||||||
|
terminal: "terminal",
|
||||||
|
times: "x",
|
||||||
|
toolbox: "wrench",
|
||||||
|
"trash-can": "trash-2",
|
||||||
|
"triangle-exclamation": "triangle-alert",
|
||||||
|
twitter: null,
|
||||||
|
upload: "upload",
|
||||||
|
user: "user",
|
||||||
|
users: "users",
|
||||||
|
vuejs: null,
|
||||||
|
"wand-magic-sparkles": "sparkles",
|
||||||
|
whatsapp: null,
|
||||||
|
workflow: "workflow",
|
||||||
|
wrench: "wrench",
|
||||||
|
x: "x",
|
||||||
|
"x-twitter": null,
|
||||||
|
xmark: "x",
|
||||||
|
youtube: null,
|
||||||
|
zap: "zap",
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Frontmatter field policy ----------------------------------------------
|
||||||
|
// Top-level keys Blume's strict schema rejects: delete the whole block, report.
|
||||||
|
const DROP = new Set([
|
||||||
|
"groups",
|
||||||
|
"hideApiMarker",
|
||||||
|
"hideFooterPagination",
|
||||||
|
"iconType",
|
||||||
|
"keywords",
|
||||||
|
"mode",
|
||||||
|
"public",
|
||||||
|
"rss",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mintlify-only keys → Blume nested target. `[parent, child]`.
|
||||||
|
const RENAME = {
|
||||||
|
canonical: ["seo", "canonical"],
|
||||||
|
"og:image": ["seo", "image"],
|
||||||
|
ogImage: ["seo", "image"],
|
||||||
|
sidebarTitle: ["sidebar", "label"],
|
||||||
|
tag: ["sidebar", "badge"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keys we deliberately do NOT auto-transform — they usually mean the page is an
|
||||||
|
// OpenAPI endpoint stub that should be deleted (Blume generates operation pages)
|
||||||
|
// or converted to `type: api`. Flag for the human; never guess.
|
||||||
|
const FLAG = new Set(["api", "asyncapi", "openapi"]);
|
||||||
|
|
||||||
|
// Which change kinds actually edit the file. Report-only kinds (flags,
|
||||||
|
// unknowns, conflicts, manual-rename notices) leave the bytes untouched.
|
||||||
|
const MUTATING = new Set(["drop", "icon-drop", "icon-remap", "rename"]);
|
||||||
|
|
||||||
|
// --- Frontmatter line model -------------------------------------------------
|
||||||
|
|
||||||
|
// Split a file into { fm, body, eol } or null when there's no `---` block.
|
||||||
|
const splitFrontmatter = (text) => {
|
||||||
|
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
||||||
|
const lines = text.split(/\r?\n/u);
|
||||||
|
if (lines[0] !== "---") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let end = -1;
|
||||||
|
for (let i = 1; i < lines.length; i += 1) {
|
||||||
|
if (lines[i] === "---") {
|
||||||
|
end = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { body: lines.slice(end + 1), eol, fm: lines.slice(1, end) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Match a top-level (column-0) `key:` line, honoring quoted keys. Because the
|
||||||
|
// key class allows `:`, backtracking resolves `og:image: x` to key `og:image`.
|
||||||
|
const topKey = (line) => {
|
||||||
|
const m = /^(?<q>["']?)(?<key>[^"'\s:][^"']*?)\k<q>\s*:(?<rest>\s.*|)$/u.exec(
|
||||||
|
line
|
||||||
|
);
|
||||||
|
if (!m || /^\s/u.test(line)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { key: m.groups.key, value: (m.groups.rest ?? "").trim() };
|
||||||
|
};
|
||||||
|
|
||||||
|
// The line index range [start, endExclusive) of a top-level key's block.
|
||||||
|
const blockRange = (fm, start) => {
|
||||||
|
let end = start + 1;
|
||||||
|
while (end < fm.length && (fm[end] === "" || /^\s/u.test(fm[end]))) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
// Trim trailing blank lines back out so the gap before the next key survives.
|
||||||
|
while (end - 1 > start && fm[end - 1] === "") {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
return [start, end];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Does the block starting at `start` carry a nested child `childKey:`?
|
||||||
|
const findChild = (fm, start, endExclusive, childKey) => {
|
||||||
|
for (let i = start + 1; i < endExclusive; i += 1) {
|
||||||
|
const m = /^\s+(?<q>["']?)(?<key>[^"'\s:][^"']*?)\k<q>\s*:/u.exec(fm[i]);
|
||||||
|
if (m && m.groups.key === childKey) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- The two passes ---------------------------------------------------------
|
||||||
|
|
||||||
|
// Remap `icon:` values in place. Mutates `fm`; returns change records.
|
||||||
|
const remapIcons = (fm) => {
|
||||||
|
const changes = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < fm.length) {
|
||||||
|
const m =
|
||||||
|
/^(?<indent>\s*)icon:\s*(?<q>["']?)(?<name>[^"'#]*?)\k<q>\s*(?<comment>#.*)?$/u.exec(
|
||||||
|
fm[i]
|
||||||
|
);
|
||||||
|
if (!m) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const raw = m.groups.name.trim();
|
||||||
|
// Empty, an object, or a template ref — leave it for the human.
|
||||||
|
if (raw === "" || raw.startsWith("{") || raw.startsWith("$")) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = raw.toLowerCase();
|
||||||
|
if (!(key in ICONS)) {
|
||||||
|
changes.push({ detail: raw, kind: "icon-unknown" });
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const lucide = ICONS[key];
|
||||||
|
if (lucide === null) {
|
||||||
|
changes.push({ detail: raw, kind: "icon-drop" });
|
||||||
|
fm.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lucide !== raw) {
|
||||||
|
const comment = m.groups.comment ? ` ${m.groups.comment}` : "";
|
||||||
|
fm[i] = `${m.groups.indent}icon: ${lucide}${comment}`;
|
||||||
|
changes.push({ detail: `${raw} → ${lucide}`, kind: "icon-remap" });
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert `child: value` under `parent`, creating the parent block if needed.
|
||||||
|
const setNested = (fm, parent, child, value) => {
|
||||||
|
for (let i = 0; i < fm.length; i += 1) {
|
||||||
|
const tk = topKey(fm[i]);
|
||||||
|
if (!tk || tk.key !== parent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.value !== "") {
|
||||||
|
// `parent: scalar` — can't nest under a scalar without clobbering it.
|
||||||
|
return { ok: false, reason: "parent-is-scalar" };
|
||||||
|
}
|
||||||
|
const [start, end] = blockRange(fm, i);
|
||||||
|
if (findChild(fm, start, end, child) !== -1) {
|
||||||
|
return { ok: false, reason: "child-exists" };
|
||||||
|
}
|
||||||
|
fm.splice(start + 1, 0, ` ${child}: ${value}`);
|
||||||
|
return { index: start + 1, ok: true };
|
||||||
|
}
|
||||||
|
// No parent block — append one at the end of the frontmatter.
|
||||||
|
const index = fm.length;
|
||||||
|
fm.push(`${parent}:`, ` ${child}: ${value}`);
|
||||||
|
return { index, ok: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drop unsupported keys, rename Mintlify-only keys, flag ambiguous ones.
|
||||||
|
const rewriteFields = (fm) => {
|
||||||
|
const changes = [];
|
||||||
|
// Walk from the bottom so splices don't shift indices we haven't visited.
|
||||||
|
for (let i = fm.length - 1; i >= 0; i -= 1) {
|
||||||
|
const tk = topKey(fm[i]);
|
||||||
|
if (!tk) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (DROP.has(tk.key)) {
|
||||||
|
const [start, end] = blockRange(fm, i);
|
||||||
|
fm.splice(start, end - start);
|
||||||
|
changes.push({ detail: tk.key, kind: "drop" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (FLAG.has(tk.key)) {
|
||||||
|
changes.push({ detail: tk.key, kind: "flag" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const target = RENAME[tk.key];
|
||||||
|
if (!target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [parent, child] = target;
|
||||||
|
const [start, end] = blockRange(fm, i);
|
||||||
|
if (end - start !== 1 || tk.value === "") {
|
||||||
|
// Multi-line or valueless source — too structured to move safely.
|
||||||
|
changes.push({
|
||||||
|
detail: `${tk.key} → ${parent}.${child}`,
|
||||||
|
kind: "rename-manual",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Remove the source line before inserting: setNested splices into the
|
||||||
|
// parent block, and when that block sits above the source key the insert
|
||||||
|
// would otherwise shift `start` onto the wrong line. Failure paths don't
|
||||||
|
// mutate `fm`, so the line can be restored as-is on conflict.
|
||||||
|
const [removed] = fm.splice(start, 1);
|
||||||
|
const placed = setNested(fm, parent, child, tk.value);
|
||||||
|
if (placed.ok) {
|
||||||
|
if (placed.index < start) {
|
||||||
|
// The insert above the cursor pushed the unvisited lines down one
|
||||||
|
// slot; revisit the current index so none of them get skipped.
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
changes.push({
|
||||||
|
detail: `${tk.key} → ${parent}.${child}`,
|
||||||
|
kind: "rename",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fm.splice(start, 0, removed);
|
||||||
|
// Target already set, or parent is a scalar — leave the source in place so
|
||||||
|
// no data is lost, and report it for manual resolution.
|
||||||
|
changes.push({
|
||||||
|
detail: `${tk.key} → ${parent}.${child} (${placed.reason})`,
|
||||||
|
kind: "rename-conflict",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes.toReversed();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Driver -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const transform = (text) => {
|
||||||
|
const split = splitFrontmatter(text);
|
||||||
|
if (!split) {
|
||||||
|
return { changed: false, changes: [], text };
|
||||||
|
}
|
||||||
|
const fm = [...split.fm];
|
||||||
|
const changes = [...remapIcons(fm), ...rewriteFields(fm)];
|
||||||
|
const rebuilt = ["---", ...fm, "---", ...split.body].join(split.eol);
|
||||||
|
const changed = rebuilt !== text && changes.some((c) => MUTATING.has(c.kind));
|
||||||
|
return { changed, changes, text: changed ? rebuilt : text };
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectFiles = (paths) => {
|
||||||
|
const out = [];
|
||||||
|
const walk = (p) => {
|
||||||
|
const st = statSync(p);
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
for (const name of readdirSync(p).toSorted()) {
|
||||||
|
if (name === "node_modules" || name.startsWith(".")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
walk(path.join(p, name));
|
||||||
|
}
|
||||||
|
} else if (/\.mdx?$/u.test(p)) {
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const p of paths.toSorted()) {
|
||||||
|
walk(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LABEL = {
|
||||||
|
drop: "dropped",
|
||||||
|
flag: "FLAG (review)",
|
||||||
|
"icon-drop": "icon dropped (no Lucide equivalent)",
|
||||||
|
"icon-remap": "icon",
|
||||||
|
"icon-unknown": "icon unknown (verify at lucide.dev)",
|
||||||
|
rename: "renamed",
|
||||||
|
"rename-conflict": "rename conflict (left in place)",
|
||||||
|
"rename-manual": "rename needs manual edit",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HELP = [
|
||||||
|
"mintlify-codemod — icon + frontmatter pass for Mintlify → Blume",
|
||||||
|
"",
|
||||||
|
" node mintlify-codemod.mjs <path...> dry run (report only)",
|
||||||
|
" node mintlify-codemod.mjs --write <path...> apply in place",
|
||||||
|
" node mintlify-codemod.mjs --json <path...> JSON report",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const main = () => {
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
if (argv.includes("--help") || argv.includes("-h") || argv.length === 0) {
|
||||||
|
process.stdout.write(`${HELP}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const write = argv.includes("--write");
|
||||||
|
const asJson = argv.includes("--json");
|
||||||
|
const paths = argv.filter((a) => !a.startsWith("--"));
|
||||||
|
const files = collectFiles(paths);
|
||||||
|
|
||||||
|
const report = [];
|
||||||
|
let changedCount = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
const before = readFileSync(file, "utf-8");
|
||||||
|
const { changed, changes, text } = transform(before);
|
||||||
|
if (changes.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (changed && write) {
|
||||||
|
writeFileSync(file, text, "utf-8");
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
changedCount += 1;
|
||||||
|
}
|
||||||
|
report.push({ changes, file: path.relative(process.cwd(), file) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asJson) {
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify({ files: report, wrote: write }, null, 2)}\n`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of report) {
|
||||||
|
process.stdout.write(`\n${entry.file}\n`);
|
||||||
|
for (const c of entry.changes) {
|
||||||
|
process.stdout.write(` ${LABEL[c.kind] ?? c.kind}: ${c.detail}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const verb = write ? "changed" : "would change";
|
||||||
|
const hint = write ? "" : " Re-run with --write to apply.";
|
||||||
|
process.stdout.write(
|
||||||
|
`\n${report.length} file(s) with findings, ${changedCount} ${verb}.${hint}\n`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: blume-update-docs
|
||||||
|
description: Keep a Blume docs site in sync with the product it documents. Audit recently merged pull requests, changelogs, config schemas, CLI help, and public APIs against the docs content, update only pages that are factually stale, verify the docs build, and open (or update) a maintenance pull request — or report a clean no-op. Use when asked to check docs for drift, refresh stale documentation, run a scheduled docs audit, or keep docs current after a release.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Update Blume Docs
|
||||||
|
|
||||||
|
Blume is a **markdown-first** documentation framework on Astro/Vite: content lives as Markdown/MDX under a content root (default `docs/`), navigation derives from the file tree plus optional `meta.ts` files, `blume build` validates frontmatter and duplicate routes, and `blume validate` checks links and anchors.
|
||||||
|
|
||||||
|
Your job is **docs maintenance, not docs authorship**: find where shipped, user-facing behavior has drifted from what the docs claim, fix exactly that, prove the site still builds, and deliver the result as a pull request. A run that finds nothing actionable ends with a short report and **no branch, no commit, no PR** — prefer a no-op over a noisy PR.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- **Only document what shipped.** Never invent features, timelines, pricing, APIs, or compatibility claims. Work behind a feature flag is not ready for docs unless the flag is enabled for the documented audience or the repo explicitly documents unreleased behavior.
|
||||||
|
- **Facts over polish.** Edit when a command, option, default, route, prop, or workflow is wrong or missing. Skip subjective rewording, marketing polish, restructuring, and formatting-only churn.
|
||||||
|
- **Smallest correct diff.** Touch the fewest pages that remove the drift. Preserve the site's voice, frontmatter style, component usage, and `meta.ts` navigation patterns.
|
||||||
|
- **Exact source-of-truth wording** for commands, flags, config keys, environment variables, routes, and version numbers — copy them from code, don't paraphrase from memory.
|
||||||
|
- **Respect the repo.** Follow `AGENTS.md`/`CLAUDE.md` conventions, don't touch generated output (`.blume/`, `dist/`), and never overwrite unrelated local changes.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Establish context.**
|
||||||
|
- Read the repo's agent/contributor instructions (`AGENTS.md`, `CLAUDE.md`, contribution docs) and honor them.
|
||||||
|
- Locate the docs app and content root: `blume.config.ts` (`content.root`), the directory of `.md`/`.mdx` pages, `meta.ts` files, and the package manager + docs build command.
|
||||||
|
- If this run was configured with a trigger, lookback window, docs path, target branch, or PR policy, honor those. Use the defaults below only where the prompt is silent.
|
||||||
|
|
||||||
|
2. **Reuse or create a maintenance branch.**
|
||||||
|
- If an open docs-maintenance PR from a previous run exists (head branch starting with `blume/`), check out and update that branch instead of opening a duplicate.
|
||||||
|
- Otherwise branch from the default branch as `blume/docs-refresh-YYYY-MM-DD`. Create the branch only once you know an edit is needed.
|
||||||
|
|
||||||
|
3. **Find drift.** Read `references/audit-checklist.md` for the full source list and change criteria, then:
|
||||||
|
- Review PRs merged into the default branch within the lookback window (default: the last 7 days) and extract the user-facing changes.
|
||||||
|
- Compare those changes — plus changelogs, release notes, config schemas, exported APIs, CLI help, and examples — against the docs content.
|
||||||
|
- Check external links only when a checked page depends on them; prefer official docs and release notes over secondary sources.
|
||||||
|
- Keep notes: what you checked, what changed upstream, and why each edit is (or isn't) needed.
|
||||||
|
|
||||||
|
4. **Update the docs.**
|
||||||
|
- Fix the stale pages. Add, rename, or remove `meta.ts` entries when pages are added, renamed, or deleted.
|
||||||
|
- Match the surrounding pages: frontmatter shape, Blume components already in use, code-fence style, root-relative internal links.
|
||||||
|
|
||||||
|
5. **Verify.**
|
||||||
|
- Run the docs build (`blume build` or the repo's documented docs QA) — it validates frontmatter and duplicate routes.
|
||||||
|
- Run `blume validate` to check internal links and anchors.
|
||||||
|
- Run lint/format/typecheck when the repo's conventions call for them on docs changes.
|
||||||
|
- Fix failures your edits caused; report pre-existing failures separately instead of fixing them in this PR.
|
||||||
|
|
||||||
|
6. **Deliver.**
|
||||||
|
- **Changes made:** commit only the maintenance edits, push the `blume/*` branch, and open or update a PR against the default branch titled like `blume: refresh docs for YYYY-MM-DD`. In the body list sources checked, docs changed, verification commands and results, skipped checks, and residual risk.
|
||||||
|
- **No changes needed:** report the PRs and docs areas checked and the no-op result. Do not create a branch, commit, or PR.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- `references/audit-checklist.md` — the source checklist, edit/skip criteria, and Blume-specific editing guidance. Read it before making docs changes.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Audit checklist
|
||||||
|
|
||||||
|
Use this checklist to decide whether a docs-maintenance run should make changes.
|
||||||
|
|
||||||
|
## Sources to check
|
||||||
|
|
||||||
|
- **Repo instructions:** `AGENTS.md`, `CLAUDE.md`, contribution docs, release docs, PR templates, and package scripts.
|
||||||
|
- **Blume config:** `blume.config.ts` — `content.root`, navigation (`meta.ts` files, tabs, selectors), site settings, AI/MCP settings, search, OpenAPI/AsyncAPI sources, theme, and export settings.
|
||||||
|
- **Public surface area:** exported package entrypoints, config schemas, component props, CLI commands and help text, route handlers, environment variables, and registry items.
|
||||||
|
- **User workflows:** quickstarts, examples, migration guides, deployment guides, screenshots, sample projects, and README snippets.
|
||||||
|
- **Recent merge signals:** PRs merged within the lookback window (default 7 days), changelogs, release notes, changesets, tags, and package version bumps.
|
||||||
|
- **Docs content:** every `.md`/`.mdx` page under the configured content root, plus custom pages and blog/changelog entries.
|
||||||
|
- **External dependencies:** official provider docs and release notes for linked integrations — only when the docs mention them or the dependency changed.
|
||||||
|
- **Generated docs surfaces:** `llms.txt`, raw Markdown URLs, MCP tools, OpenAPI pages, search, sitemap, robots, RSS, and OG behavior when relevant.
|
||||||
|
|
||||||
|
## Change criteria
|
||||||
|
|
||||||
|
Make a docs edit when at least one condition is true:
|
||||||
|
|
||||||
|
- A command, config option, environment variable, route, CLI flag, component prop, or default value changed.
|
||||||
|
- A documented workflow no longer works or misses a required step.
|
||||||
|
- A page promises a feature, provider, adapter, or integration the code no longer supports.
|
||||||
|
- A new user-facing capability shipped but is absent from the appropriate docs page.
|
||||||
|
- A link points at moved, removed, or outdated primary documentation.
|
||||||
|
- A changelog or release page needs an entry for shipped user-facing behavior.
|
||||||
|
|
||||||
|
Skip the edit when the only available change is subjective polish, wording preference, duplicated information, speculative future work, or behavior still hidden behind a feature flag.
|
||||||
|
|
||||||
|
## Blume editing guidance
|
||||||
|
|
||||||
|
- Keep frontmatter short and factual; use `title` and `description` consistently with nearby pages. Blume's frontmatter schema is **strict** — unknown keys are build errors.
|
||||||
|
- Preserve existing page order and `defineMeta` style; update `pages` arrays when adding, renaming, or removing pages.
|
||||||
|
- Use the Blume components already present in the docs (callout directives, steps, cards) instead of inventing new markup patterns.
|
||||||
|
- Match nearby code fences: filenames, language tags, and line numbers where the surrounding docs use them.
|
||||||
|
- Keep internal links root-relative (`/docs/...`).
|
||||||
|
- Do not edit generated `.blume/` or `dist/` output.
|
||||||
|
|
||||||
|
## PR notes
|
||||||
|
|
||||||
|
Include these sections in the PR body or no-op summary:
|
||||||
|
|
||||||
|
- Sources checked
|
||||||
|
- Docs changed
|
||||||
|
- Verification run (commands and results)
|
||||||
|
- Skipped checks, with reasons
|
||||||
|
- Remaining risk or follow-up
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: blume
|
||||||
|
description: Build and maintain documentation sites with Blume, the markdown-first docs framework on Astro and Vite. Use when working in a project that depends on `blume`, when scaffolding or configuring a docs site, writing Markdown/MDX content, tuning navigation/search/theming/SEO/AI features, running the `blume` CLI (init, dev, build, eject), or editing `blume.config.ts` and `meta.ts` files.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Blume
|
||||||
|
|
||||||
|
Blume is an open-source, **markdown-first** documentation framework built on Astro and Vite. Drop Markdown or MDX into a folder, run `blume dev`, and get a production-grade docs site — navigation, search, theming, Open Graph images, and a rich component library — with no app boilerplate to write or maintain.
|
||||||
|
|
||||||
|
The core idea: **the framework _is_ the template.** There's no starter to clone and no project to own before you've written a word. The only thing you touch is your content. When you outgrow the defaults, you add configuration one file at a time — and you can `blume eject` to a plain Astro project the day you want full control.
|
||||||
|
|
||||||
|
## What makes it different
|
||||||
|
|
||||||
|
- **Fast by default** — Static HTML on Astro/Vite. The core theme ships no client framework JS so pages score well on Core Web Vitals out of the box. You opt into server features only when you need them.
|
||||||
|
- **AI-ready out of the box** — Emits `llms.txt`/`llms-full.txt`, serves any page's raw Markdown by appending `.md` to its URL, offers **Copy as Markdown** and **Open in chat** on every page, and can host an optional **Ask AI** assistant or an **MCP server** so coding agents read your docs directly.
|
||||||
|
- **Zero configuration — even the template** — A folder of docs is a complete project. Navigation is inferred from files, search works in dev and production with no hosted service, and theming is a handful of tokens.
|
||||||
|
- **Type-safe to the core** — `blume.config.ts` and every `meta.ts` are real TypeScript, validated by a schema and authored with `defineConfig` and `defineMeta`. Your editor autocompletes options and catches mistakes before a build.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Blume needs **Node.js 22.12 or newer**. From an empty or existing project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i blume # install the package
|
||||||
|
blume init # scaffold: docs/index.mdx + blume.config.ts
|
||||||
|
blume dev # dev server with hot reload
|
||||||
|
blume build # static HTML to dist/, with a local search index
|
||||||
|
```
|
||||||
|
|
||||||
|
Blume works with any package manager and never requires you to set up Astro or Tailwind yourself.
|
||||||
|
|
||||||
|
### Writing a page
|
||||||
|
|
||||||
|
Every page is Markdown or MDX with a little frontmatter. The `title` and `description` render as the page heading and intro automatically; built-in components (callouts, cards, tabs, steps, and more) need **no imports**.
|
||||||
|
|
||||||
|
```mdx
|
||||||
|
---
|
||||||
|
title: Introduction
|
||||||
|
description: Welcome to my docs.
|
||||||
|
---
|
||||||
|
|
||||||
|
Welcome! Use **Markdown** and built-in components — no imports required:
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Blume ships callouts, cards, tabs, steps, and more.
|
||||||
|
:::
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigation, search, and page metadata are inferred from your files as you add them.
|
||||||
|
|
||||||
|
## What's included
|
||||||
|
|
||||||
|
- **Components** — callouts, cards, steps, tabs, accordions, badges, file trees, and parameter tables, usable in MDX with no imports.
|
||||||
|
- **Local search** — Orama in dev and production; Pagefind is one flag away for large sites. No hosted index.
|
||||||
|
- **AI** — `llms.txt`, raw Markdown URLs, Copy as Markdown, Open in chat, an Ask AI assistant, and an MCP server endpoint served by the docs site itself.
|
||||||
|
- **Navigation** — inferred from files, refined with `meta.ts` or config.
|
||||||
|
- **SEO** — metadata, Open Graph images, RSS feeds, and JSON-LD.
|
||||||
|
- **Customization** — component overrides, React islands, custom pages, theme tokens, and a source-component registry via `blume add`.
|
||||||
|
- **Eject** — `blume eject` produces a standalone Astro project that still uses the `blume` package.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The Blume CLI discovers your content, builds a content graph, and generates a hidden Astro project under `.blume/` that it drives for dev and build. The generated runtime is an implementation detail — you write Markdown, Blume handles the rest — until you choose to eject and own it.
|
||||||
|
|
||||||
|
## Full documentation
|
||||||
|
|
||||||
|
This is a high-level overview. For complete, authoritative docs — configuration reference, every CLI command and flag, component APIs, content authoring, navigation, search, SEO, AI features, theming, and deployment — read the bundled docs in the installed package:
|
||||||
|
|
||||||
|
```
|
||||||
|
node_modules/blume/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
Start with `node_modules/blume/docs/index.mdx` (Introduction) and `node_modules/blume/docs/01-quickstart.mdx`, then browse the `configuration/`, `content/`, `reference/`, and `advanced/` sections for specifics.
|
||||||
@@ -0,0 +1,798 @@
|
|||||||
|
---
|
||||||
|
name: brandkit
|
||||||
|
description: Premium brand-kit image generation skill for creating high-end brand-guidelines boards, logo systems, identity decks, and visual-world presentations. Trained for minimalist, cinematic, editorial, dark-tech, luxury, cultural, security, gaming, developer-tool, and consumer-app brand systems. Optimized for intentional logo concepting, refined composition, sparse typography, strong symbolic meaning, premium mockups, art-directed imagery, and flexible grid layouts.
|
||||||
|
---
|
||||||
|
|
||||||
|
# BRANDKIT IMAGE GENERATION SKILL
|
||||||
|
|
||||||
|
You are an elite brand identity art director, logo designer, visual-system strategist, and presentation designer.
|
||||||
|
|
||||||
|
Your job is to generate premium brand-kit images that feel like they came from a serious identity studio.
|
||||||
|
|
||||||
|
The output must feel:
|
||||||
|
- intentional
|
||||||
|
- premium
|
||||||
|
- minimal
|
||||||
|
- coherent
|
||||||
|
- strategic
|
||||||
|
- visually expensive
|
||||||
|
- brand-system driven
|
||||||
|
- presentation-ready
|
||||||
|
|
||||||
|
Do not generate generic logos.
|
||||||
|
Do not generate random mockups.
|
||||||
|
Do not generate messy AI moodboards.
|
||||||
|
|
||||||
|
Create a complete brand world in one image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# REFERENCE STYLE DNA
|
||||||
|
|
||||||
|
The desired visual quality is inspired by premium brand-guidelines decks with:
|
||||||
|
|
||||||
|
- dark charcoal outer canvas
|
||||||
|
- clean grid-based presentation boards
|
||||||
|
- strong gutters between panels
|
||||||
|
- restrained visual density
|
||||||
|
- very sparse typography
|
||||||
|
- large negative space
|
||||||
|
- cinematic brand atmosphere
|
||||||
|
- simple but memorable logo marks
|
||||||
|
- UI mockups used as brand applications
|
||||||
|
- browser chrome / app headers / terminal frames
|
||||||
|
- image-led panels with subtle overlays
|
||||||
|
- halftone, grain, scanline, or print texture
|
||||||
|
- geometric construction diagrams
|
||||||
|
- small labels and page-number details
|
||||||
|
- muted but powerful accent colors
|
||||||
|
- logo repeated across multiple touchpoints
|
||||||
|
- one strong brand idea per board
|
||||||
|
|
||||||
|
The references are not a fixed style.
|
||||||
|
They define the quality bar, restraint, and presentation logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# CORE PRINCIPLE
|
||||||
|
|
||||||
|
A premium brand kit is not decoration.
|
||||||
|
|
||||||
|
It is a visual argument for why the brand exists.
|
||||||
|
|
||||||
|
Every generated board must answer:
|
||||||
|
|
||||||
|
1. What does this brand represent?
|
||||||
|
2. What is the core metaphor?
|
||||||
|
3. How does the logo express that?
|
||||||
|
4. How does the system scale across UI, print, image, and detail?
|
||||||
|
5. Why does the whole thing feel ownable?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DEFAULT OUTPUT
|
||||||
|
|
||||||
|
Unless the user specifies otherwise:
|
||||||
|
|
||||||
|
- Generate one brand-kit overview image
|
||||||
|
- Default layout: `3 × 3`
|
||||||
|
- Default aspect ratio: `4:3` or `16:10`
|
||||||
|
- Use a clean presentation grid
|
||||||
|
- Use consistent gutters
|
||||||
|
- Use minimal text
|
||||||
|
- Make every panel feel connected
|
||||||
|
|
||||||
|
Allowed layouts:
|
||||||
|
- `3 × 3` full identity system
|
||||||
|
- `2 × 3` cinematic brand deck overview
|
||||||
|
- `2 × 2` compact concept board
|
||||||
|
- `1 × 3` horizontal brand strip
|
||||||
|
- `4 × 2` wide contact-sheet layout
|
||||||
|
- custom layout when requested
|
||||||
|
|
||||||
|
If the user gives references, match their quality and rhythm, not their exact content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# BRAND STRATEGY FIRST
|
||||||
|
|
||||||
|
Before generating, infer the brand strategy.
|
||||||
|
|
||||||
|
Think through:
|
||||||
|
|
||||||
|
- category
|
||||||
|
- audience
|
||||||
|
- product function
|
||||||
|
- emotional promise
|
||||||
|
- cultural position
|
||||||
|
- trust level
|
||||||
|
- visual world
|
||||||
|
- symbolic metaphor
|
||||||
|
- what the brand should avoid
|
||||||
|
|
||||||
|
The visual system must be based on meaning.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Category | Core Ideas | Possible Symbol Logic |
|
||||||
|
|---|---|---|
|
||||||
|
| Developer tool | building, speed, precision, control | cursor, frame, bolt, scaffold, grid |
|
||||||
|
| AI assistant | delegation, intelligence, clarity | spark, orbit, signal, path, node |
|
||||||
|
| Security | protection, vigilance, boundary | shield, eye, seal, protected core |
|
||||||
|
| Gaming / betting | chance, reward, tension, speed | dice, gem, card, signal, trophy |
|
||||||
|
| Voice AI | sound, rhythm, command, flow | waveform, mic, orb, speech path |
|
||||||
|
| Compliance | trust, order, rules, protection | seal, dog, badge, document, shield |
|
||||||
|
| Drone / robotics | flight, control, vision, mission | wing, owl, crosshair, path, zone |
|
||||||
|
| Luxury / editorial | taste, material, ritual, restraint | monogram, seal, paper, emboss, mark |
|
||||||
|
| Productivity | focus, momentum, clarity | path, check, block, calendar, light |
|
||||||
|
|
||||||
|
Do not pick symbols randomly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# LOGO GENERATION STANDARD
|
||||||
|
|
||||||
|
The logo must be professional.
|
||||||
|
|
||||||
|
It should be:
|
||||||
|
- simple
|
||||||
|
- memorable
|
||||||
|
- symbolic
|
||||||
|
- scalable
|
||||||
|
- ownable
|
||||||
|
- visually balanced
|
||||||
|
- connected to the brand idea
|
||||||
|
- usable as icon, wordmark, badge, UI mark, and pattern
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
- generic lightning bolts unless strongly justified
|
||||||
|
- random animals
|
||||||
|
- fake luxury crests
|
||||||
|
- copied famous marks
|
||||||
|
- overcomplicated symbols
|
||||||
|
- clipart-style icons
|
||||||
|
- meaningless sparkles
|
||||||
|
- inconsistent logo variants
|
||||||
|
|
||||||
|
The logo should feel like it came from research and reduction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# LOGO CONCEPT METHODS
|
||||||
|
|
||||||
|
Use one or combine two maximum.
|
||||||
|
|
||||||
|
## 1. Monogram + Meaning
|
||||||
|
|
||||||
|
Combine the brand initial with a metaphor.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `K` + kite / frame / direction
|
||||||
|
- `N` + path / folded system
|
||||||
|
- `S` + sound wave / speech flow
|
||||||
|
- `A` + ascent / architecture / momentum
|
||||||
|
|
||||||
|
Do not make a boring letter icon.
|
||||||
|
Use negative space, cuts, folds, or geometry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Product Action
|
||||||
|
|
||||||
|
Turn the product's main action into a symbol.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- build → frame, scaffold, block, cursor
|
||||||
|
- protect → shield, boundary, watch mark
|
||||||
|
- convert → switch, arrow, transformation shape
|
||||||
|
- speak → waveform, mic, pulse
|
||||||
|
- hunt threats → eye, raptor, radar, trace
|
||||||
|
- automate → loop, handoff, path
|
||||||
|
|
||||||
|
Make it abstract and premium, not literal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Metaphor Fusion
|
||||||
|
|
||||||
|
Combine two meaningful ideas into one reduced mark.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- owl + drone vision
|
||||||
|
- shield + mountain
|
||||||
|
- moon + waveform
|
||||||
|
- dog + compliance seal
|
||||||
|
- dice + mobile game economy
|
||||||
|
- cursor + lightning speed
|
||||||
|
- kite + product frame
|
||||||
|
|
||||||
|
The fusion should be subtle and readable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Negative Space
|
||||||
|
|
||||||
|
Use empty space to create intelligence.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- hidden arrow
|
||||||
|
- protected center
|
||||||
|
- cutout initial
|
||||||
|
- internal path
|
||||||
|
- folded corner
|
||||||
|
- eye formed by crossing shapes
|
||||||
|
|
||||||
|
Negative space should be crisp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Construction Geometry
|
||||||
|
|
||||||
|
Create a mark from a clear system.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- circles
|
||||||
|
- diagonal cuts
|
||||||
|
- grids
|
||||||
|
- frames
|
||||||
|
- modular blocks
|
||||||
|
- layered cards
|
||||||
|
- orbital paths
|
||||||
|
- crosshairs
|
||||||
|
- measured linework
|
||||||
|
|
||||||
|
One panel can show construction logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# BOARD COMPOSITION DNA
|
||||||
|
|
||||||
|
A strong brand-kit board should feel like a curated sequence.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- large calm cover panel
|
||||||
|
- one digital mockup panel
|
||||||
|
- one image-led atmosphere panel
|
||||||
|
- one system/construction panel
|
||||||
|
- one physical or icon application panel
|
||||||
|
- one quiet tagline panel
|
||||||
|
|
||||||
|
Do not make every panel equally loud.
|
||||||
|
|
||||||
|
The board should have rhythm:
|
||||||
|
- quiet
|
||||||
|
- functional
|
||||||
|
- emotional
|
||||||
|
- technical
|
||||||
|
- atmospheric
|
||||||
|
- detailed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DEFAULT 3 × 3 PANEL SYSTEM
|
||||||
|
|
||||||
|
Use this if no layout is specified:
|
||||||
|
|
||||||
|
## 1. Logo Cover
|
||||||
|
Large logo and wordmark.
|
||||||
|
Minimal title.
|
||||||
|
Strong negative space.
|
||||||
|
|
||||||
|
## 2. Logo Construction
|
||||||
|
Symbol breakdown, grid, geometry, or negative-space logic.
|
||||||
|
Show why the mark exists.
|
||||||
|
|
||||||
|
## 3. Digital Application
|
||||||
|
Browser chrome, app header, terminal, dashboard fragment, or app icon.
|
||||||
|
|
||||||
|
## 4. Brand Essence
|
||||||
|
One short tagline.
|
||||||
|
Large readable typography.
|
||||||
|
Sparse composition.
|
||||||
|
|
||||||
|
## 5. Color System
|
||||||
|
Swatches, gradient strips, color discs, material chips, or palette cards.
|
||||||
|
|
||||||
|
## 6. Typography
|
||||||
|
Large type specimen, alphabet row, or primary/secondary type pairing.
|
||||||
|
|
||||||
|
## 7. Physical Application
|
||||||
|
Card, folder, badge, poster, label, seal, packaging, or object mockup.
|
||||||
|
|
||||||
|
## 8. Image Direction
|
||||||
|
Cinematic landscape, product crop, halftone poster, editorial scene, material texture.
|
||||||
|
|
||||||
|
## 9. System Detail
|
||||||
|
UI chips, input bar, command line, icon row, badge system, component strip, pattern detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2 × 3 REFERENCE-STYLE LAYOUT
|
||||||
|
|
||||||
|
For boards like the uploaded references, use:
|
||||||
|
|
||||||
|
1. **Logo / Wordmark**
|
||||||
|
- centered or offset
|
||||||
|
- extremely minimal
|
||||||
|
|
||||||
|
2. **Browser / Product Surface**
|
||||||
|
- browser bar, app frame, prompt input, or URL field
|
||||||
|
|
||||||
|
3. **Command / Functional Panel**
|
||||||
|
- terminal, prompt bar, input state, install command, dashboard fragment
|
||||||
|
|
||||||
|
4. **Atmosphere / Campaign Image**
|
||||||
|
- halftone landscape, cinematic image, product-world visual, or art-directed photo
|
||||||
|
|
||||||
|
5. **Symbol / Construction / Badge**
|
||||||
|
- logo mark in target, seal, geometric frame, icon construction
|
||||||
|
|
||||||
|
6. **Tagline / System Promise**
|
||||||
|
- one short line
|
||||||
|
- large type
|
||||||
|
- quiet background
|
||||||
|
|
||||||
|
This layout should feel like a premium mini-deck.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# VISUAL MODES
|
||||||
|
|
||||||
|
Choose based on the brand.
|
||||||
|
|
||||||
|
## Dark Developer / Builder
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
developer tools, coding agents, infra, automation, AI builders.
|
||||||
|
|
||||||
|
Visual cues:
|
||||||
|
- near-black panels
|
||||||
|
- monospace accents
|
||||||
|
- command lines
|
||||||
|
- terminal windows
|
||||||
|
- prompt bars
|
||||||
|
- subtle grid
|
||||||
|
- cyan, blue, coral, or lime accents
|
||||||
|
- pixel or CRT texture if appropriate
|
||||||
|
|
||||||
|
Logo logic:
|
||||||
|
- cursor + frame
|
||||||
|
- bolt + build speed
|
||||||
|
- scaffold + monogram
|
||||||
|
- terminal glyph + symbol
|
||||||
|
- modular construction mark
|
||||||
|
|
||||||
|
Mood:
|
||||||
|
precise, sharp, confident, builder-native.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dark Product / Operator
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
business tools, growth tools, sales agents, automation, productivity.
|
||||||
|
|
||||||
|
Visual cues:
|
||||||
|
- black / dark red / amber
|
||||||
|
- glowing UI chips
|
||||||
|
- card systems
|
||||||
|
- segmented flows
|
||||||
|
- icon rows
|
||||||
|
- reward/progress motifs
|
||||||
|
- minimal hero text
|
||||||
|
|
||||||
|
Logo logic:
|
||||||
|
- signal, gift, path, operator mark, switch, loop, command system
|
||||||
|
|
||||||
|
Mood:
|
||||||
|
fast, operational, tactical, premium.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dark Nature / Calm System
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
strategy, travel, wellness, climate, quiet premium SaaS.
|
||||||
|
|
||||||
|
Visual cues:
|
||||||
|
- deep green
|
||||||
|
- lime accent
|
||||||
|
- misty landscapes
|
||||||
|
- image UI circles
|
||||||
|
- soft overlays
|
||||||
|
- calm page labels
|
||||||
|
- dark editorial grid
|
||||||
|
|
||||||
|
Logo logic:
|
||||||
|
- path, leaf, moon, horizon, compass, portal, folded mark
|
||||||
|
|
||||||
|
Mood:
|
||||||
|
calm, trustworthy, focused.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dark Security / Threat Intelligence
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
security, compliance, monitoring, network products.
|
||||||
|
|
||||||
|
Visual cues:
|
||||||
|
- black/navy
|
||||||
|
- shield forms
|
||||||
|
- radar lines
|
||||||
|
- threat labels
|
||||||
|
- subtle motion traces
|
||||||
|
- red/blue alert chips
|
||||||
|
- controlled gradients
|
||||||
|
|
||||||
|
Logo logic:
|
||||||
|
- shield, raptor, eye, watch, boundary, protected core
|
||||||
|
|
||||||
|
Mood:
|
||||||
|
serious, vigilant, precise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Light Editorial / Compliance
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
legal, privacy, compliance, documents, trust brands.
|
||||||
|
|
||||||
|
Visual cues:
|
||||||
|
- warm ivory
|
||||||
|
- paper texture
|
||||||
|
- small serif labels
|
||||||
|
- seals / badges
|
||||||
|
- color wheel / palette object
|
||||||
|
- calm stationery
|
||||||
|
- deep blue, red, gold accents
|
||||||
|
|
||||||
|
Logo logic:
|
||||||
|
- seal, dog, shield, document, stamp, monogram
|
||||||
|
|
||||||
|
Mood:
|
||||||
|
trustworthy, refined, institutional but modern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Luxury / Beauty / Fashion
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
beauty, fashion, hospitality, premium services.
|
||||||
|
|
||||||
|
Visual cues:
|
||||||
|
- ivory / stone / espresso
|
||||||
|
- serif wordmark
|
||||||
|
- elegant monogram
|
||||||
|
- paper grain
|
||||||
|
- embossing
|
||||||
|
- product labels
|
||||||
|
- editorial crops
|
||||||
|
- soft shadows
|
||||||
|
|
||||||
|
Logo logic:
|
||||||
|
- monogram, seal, petal, vessel, ritual object, refined typographic mark
|
||||||
|
|
||||||
|
Mood:
|
||||||
|
tasteful, adult, expensive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voice / Communication
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
voice AI, chat, assistants, speech, audio.
|
||||||
|
|
||||||
|
Visual cues:
|
||||||
|
- dark indigo
|
||||||
|
- lilac glow
|
||||||
|
- waveform
|
||||||
|
- mic motif
|
||||||
|
- phone crop
|
||||||
|
- command input
|
||||||
|
- app icon
|
||||||
|
|
||||||
|
Logo logic:
|
||||||
|
- wave + initial
|
||||||
|
- sound orb
|
||||||
|
- speech path
|
||||||
|
- microphone abstraction
|
||||||
|
- pulse ring
|
||||||
|
|
||||||
|
Mood:
|
||||||
|
fluid, intelligent, intimate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cultural / Experimental
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
music, creative tools, events, gaming-adjacent, cultural products.
|
||||||
|
|
||||||
|
Visual cues:
|
||||||
|
- halftone
|
||||||
|
- CRT texture
|
||||||
|
- analog print
|
||||||
|
- bold accent color
|
||||||
|
- poster-style panels
|
||||||
|
- unexpected image crops
|
||||||
|
- simple but punchy logo
|
||||||
|
|
||||||
|
Logo logic:
|
||||||
|
- custom wordmark
|
||||||
|
- icon with attitude
|
||||||
|
- symbolic mascot
|
||||||
|
- print-inspired mark
|
||||||
|
|
||||||
|
Mood:
|
||||||
|
memorable, creative, still controlled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PREMIUM DETAIL LANGUAGE
|
||||||
|
|
||||||
|
Use details like:
|
||||||
|
- small page numbers
|
||||||
|
- tiny footer labels
|
||||||
|
- precise alignment marks
|
||||||
|
- construction lines
|
||||||
|
- subtle crosshair grids
|
||||||
|
- thin rules
|
||||||
|
- browser bars
|
||||||
|
- rounded rectangles
|
||||||
|
- image masks
|
||||||
|
- soft shadows
|
||||||
|
- low-opacity texture
|
||||||
|
- halftone image treatment
|
||||||
|
- one highlighted word
|
||||||
|
- one accent chip
|
||||||
|
- one strong icon state
|
||||||
|
|
||||||
|
Do not overuse them.
|
||||||
|
|
||||||
|
Premium detail should reward looking closer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TEXT RULES
|
||||||
|
|
||||||
|
Use very little text.
|
||||||
|
|
||||||
|
Good text:
|
||||||
|
- brand name
|
||||||
|
- one tagline
|
||||||
|
- one URL
|
||||||
|
- one command
|
||||||
|
- 2–5 section labels
|
||||||
|
- short UI chips
|
||||||
|
|
||||||
|
Bad text:
|
||||||
|
- long paragraphs
|
||||||
|
- tiny fake body copy
|
||||||
|
- lots of menu items
|
||||||
|
- lorem ipsum
|
||||||
|
- dense explanations
|
||||||
|
- unreadable labels
|
||||||
|
|
||||||
|
Text should be large enough and sparse enough to render well.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TAGLINE STYLE
|
||||||
|
|
||||||
|
Taglines should be short and specific.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
- "What will you build today?"
|
||||||
|
- "Nothing random."
|
||||||
|
- "Your network. Our watch."
|
||||||
|
- "Build better."
|
||||||
|
- "On guard."
|
||||||
|
- "Every mission under control."
|
||||||
|
- "Everything operators need."
|
||||||
|
- "Clarity builds confidence."
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
- generic corporate slogans
|
||||||
|
- long marketing copy
|
||||||
|
- buzzword soup
|
||||||
|
- fake inspirational fluff
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# IMAGE DIRECTION
|
||||||
|
|
||||||
|
Images should feel art-directed.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- cinematic mountains
|
||||||
|
- dusk skies
|
||||||
|
- landscapes with brand overlays
|
||||||
|
- halftone clouds
|
||||||
|
- CRT screen scenes
|
||||||
|
- dark product closeups
|
||||||
|
- dramatic object crops
|
||||||
|
- textured paper backgrounds
|
||||||
|
- moody architecture
|
||||||
|
- abstract but controlled visual systems
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
- generic stock people
|
||||||
|
- random office photos
|
||||||
|
- cliché robot imagery
|
||||||
|
- overbusy scenes
|
||||||
|
- unrelated imagery
|
||||||
|
|
||||||
|
Images should match the palette and metaphor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# MOCKUP DIRECTION
|
||||||
|
|
||||||
|
Mockups should be minimal and believable.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- browser chrome
|
||||||
|
- URL bar
|
||||||
|
- terminal window
|
||||||
|
- command prompt
|
||||||
|
- app icon
|
||||||
|
- phone corner crop
|
||||||
|
- card stack
|
||||||
|
- badge
|
||||||
|
- seal
|
||||||
|
- folder
|
||||||
|
- UI chips
|
||||||
|
- dashboard fragment
|
||||||
|
- input bar
|
||||||
|
- product label
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
- full fake dashboards with too much data
|
||||||
|
- cheap glossy mockups
|
||||||
|
- random device overload
|
||||||
|
- busy app screens
|
||||||
|
- excessive icons
|
||||||
|
|
||||||
|
Mockups are identity applications, not feature demos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# COLOR DISCIPLINE
|
||||||
|
|
||||||
|
Use one dominant palette.
|
||||||
|
|
||||||
|
Default:
|
||||||
|
- base color
|
||||||
|
- primary accent
|
||||||
|
- secondary accent
|
||||||
|
- neutrals
|
||||||
|
|
||||||
|
Good reference-style palettes:
|
||||||
|
- black + cyan + muted coral
|
||||||
|
- black + red + cream + blue
|
||||||
|
- forest green + lime + fog gray
|
||||||
|
- navy + white + steel
|
||||||
|
- ivory + deep blue + red + gold
|
||||||
|
- black + lilac + soft purple
|
||||||
|
- black + amber + red
|
||||||
|
- charcoal + white + pale blue
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- accents must repeat across panels
|
||||||
|
- no random rainbow unless requested
|
||||||
|
- no generic purple-blue AI glow unless appropriate
|
||||||
|
- one accent can carry the entire system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ANTI-GENERIC RULES
|
||||||
|
|
||||||
|
Never make:
|
||||||
|
- random floating icons
|
||||||
|
- generic startup gradients
|
||||||
|
- overdesigned logos
|
||||||
|
- meaningless blobs
|
||||||
|
- messy layout collages
|
||||||
|
- fake tiny UI
|
||||||
|
- inconsistent logo marks
|
||||||
|
- too many colors
|
||||||
|
- cheap neon
|
||||||
|
- stock-template brand boards
|
||||||
|
- corporate PowerPoint slides
|
||||||
|
- soulless SaaS dashboards
|
||||||
|
|
||||||
|
Make the design quieter, sharper, and more intentional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# REFERENCE USAGE
|
||||||
|
|
||||||
|
When the user provides references:
|
||||||
|
|
||||||
|
Extract:
|
||||||
|
- layout rhythm
|
||||||
|
- grid style
|
||||||
|
- spacing
|
||||||
|
- typography scale
|
||||||
|
- visual density
|
||||||
|
- logo placement
|
||||||
|
- amount of text
|
||||||
|
- image treatment
|
||||||
|
- accent color logic
|
||||||
|
- brand-system behavior
|
||||||
|
|
||||||
|
Do not copy:
|
||||||
|
- exact logo
|
||||||
|
- exact brand name
|
||||||
|
- exact composition
|
||||||
|
- exact slogan
|
||||||
|
- unique visual asset
|
||||||
|
|
||||||
|
Use references as quality training, not as templates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PROMPT TEMPLATE
|
||||||
|
|
||||||
|
Use this structure internally:
|
||||||
|
|
||||||
|
Create a premium brand-kit overview image for "[BRAND NAME]".
|
||||||
|
|
||||||
|
Brand strategy:
|
||||||
|
- category: [category]
|
||||||
|
- audience: [audience]
|
||||||
|
- personality: [traits]
|
||||||
|
- core metaphor: [metaphor]
|
||||||
|
- logo idea: [how the mark combines symbol + name + category meaning]
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
[3×3 / 2×3 / custom] grid on a dark or light presentation canvas with strong gutters, clean alignment, and refined negative space.
|
||||||
|
|
||||||
|
Panels:
|
||||||
|
- logo cover
|
||||||
|
- logo concept / construction
|
||||||
|
- digital application
|
||||||
|
- tagline / brand essence
|
||||||
|
- color system
|
||||||
|
- typography
|
||||||
|
- physical application
|
||||||
|
- image direction
|
||||||
|
- system detail
|
||||||
|
|
||||||
|
Visual mode:
|
||||||
|
[mode]
|
||||||
|
|
||||||
|
Palette:
|
||||||
|
[disciplined palette]
|
||||||
|
|
||||||
|
Style:
|
||||||
|
premium, sparse, cinematic, intentional, polished, brand-guidelines deck, no clutter, no copied real-world logos.
|
||||||
|
|
||||||
|
Typography:
|
||||||
|
readable, minimal, high hierarchy, no tiny fake text.
|
||||||
|
|
||||||
|
Logo:
|
||||||
|
professional, symbolic, simple, ownable, based on the brand's purpose, repeated consistently across panels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# FINAL OUTPUT STANDARD
|
||||||
|
|
||||||
|
The image must look like:
|
||||||
|
- a premium identity deck
|
||||||
|
- a senior designer's presentation board
|
||||||
|
- a brand-system case study
|
||||||
|
- a visual launch direction
|
||||||
|
- a professional logo concept board
|
||||||
|
|
||||||
|
The final result should be:
|
||||||
|
- clean
|
||||||
|
- strategic
|
||||||
|
- symbolic
|
||||||
|
- minimal
|
||||||
|
- coherent
|
||||||
|
- premium
|
||||||
|
- art-directed
|
||||||
|
- implementation-friendly
|
||||||
|
- stronger than normal AI-generated brand visuals
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
---
|
||||||
|
name: bun-development
|
||||||
|
description: "Fast, modern JavaScript/TypeScript development with the Bun runtime, inspired by [oven-sh/bun](https://github.com/oven-sh/bun)."
|
||||||
|
risk: critical
|
||||||
|
source: community
|
||||||
|
date_added: "2026-02-27"
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⚡ Bun Development
|
||||||
|
|
||||||
|
> Fast, modern JavaScript/TypeScript development with the Bun runtime, inspired by [oven-sh/bun](https://github.com/oven-sh/bun).
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when:
|
||||||
|
|
||||||
|
- Starting new JS/TS projects with Bun
|
||||||
|
- Migrating from Node.js to Bun
|
||||||
|
- Optimizing development speed
|
||||||
|
- Using Bun's built-in tools (bundler, test runner)
|
||||||
|
- Troubleshooting Bun-specific issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Getting Started
|
||||||
|
|
||||||
|
### 1.1 Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS / Linux
|
||||||
|
brew install oven-sh/bun/bun
|
||||||
|
|
||||||
|
# Alternative: download the official installer, inspect it, then execute it
|
||||||
|
tmpdir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
|
curl -fsSLo "$tmpdir/bun-install.sh" https://bun.sh/install
|
||||||
|
cat "$tmpdir/bun-install.sh" # review the full installer before executing
|
||||||
|
bash "$tmpdir/bun-install.sh"
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
powershell -NoProfile -Command "Invoke-WebRequest https://bun.sh/install.ps1 -OutFile $env:TEMP\\bun-install.ps1; Get-Content $env:TEMP\\bun-install.ps1 -TotalCount 120; powershell -ExecutionPolicy Bypass -File $env:TEMP\\bun-install.ps1"
|
||||||
|
|
||||||
|
# Homebrew
|
||||||
|
brew tap oven-sh/bun
|
||||||
|
brew install bun
|
||||||
|
|
||||||
|
# npm (if needed)
|
||||||
|
npm install -g bun
|
||||||
|
|
||||||
|
# Upgrade
|
||||||
|
bun upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Why Bun?
|
||||||
|
|
||||||
|
| Feature | Bun | Node.js |
|
||||||
|
| :-------------- | :------------- | :-------------------------- |
|
||||||
|
| Startup time | ~25ms | ~100ms+ |
|
||||||
|
| Package install | 10-100x faster | Baseline |
|
||||||
|
| TypeScript | Native | Requires transpiler |
|
||||||
|
| JSX | Native | Requires transpiler |
|
||||||
|
| Test runner | Built-in | External (Jest, Vitest) |
|
||||||
|
| Bundler | Built-in | External (Webpack, esbuild) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Project Setup
|
||||||
|
|
||||||
|
### 2.1 Create New Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize project
|
||||||
|
bun init
|
||||||
|
|
||||||
|
# Creates:
|
||||||
|
# ├── package.json
|
||||||
|
# ├── tsconfig.json
|
||||||
|
# ├── index.ts
|
||||||
|
# └── README.md
|
||||||
|
|
||||||
|
# With specific template
|
||||||
|
bun create <template> <project-name>
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
bun create react my-app # React app
|
||||||
|
bun create next my-app # Next.js app
|
||||||
|
bun create vite my-app # Vite app
|
||||||
|
bun create elysia my-api # Elysia API
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-bun-project",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"module": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --watch index.ts",
|
||||||
|
"start": "bun run index.ts",
|
||||||
|
"test": "bun test",
|
||||||
|
"build": "bun build ./index.ts --outdir ./dist",
|
||||||
|
"lint": "bunx eslint ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 tsconfig.json (Bun-optimized)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"composite": true,
|
||||||
|
"strict": true,
|
||||||
|
"downlevelIteration": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"types": ["bun-types"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Package Management
|
||||||
|
|
||||||
|
### 3.1 Installing Packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install from package.json
|
||||||
|
bun install # or 'bun i'
|
||||||
|
|
||||||
|
# Add dependencies
|
||||||
|
bun add express # Regular dependency
|
||||||
|
bun add -d typescript # Dev dependency
|
||||||
|
bun add -D @types/node # Dev dependency (alias)
|
||||||
|
bun add --optional pkg # Optional dependency
|
||||||
|
|
||||||
|
# From specific registry
|
||||||
|
bun add lodash --registry https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# Install specific version
|
||||||
|
bun add react@18.2.0
|
||||||
|
bun add react@latest
|
||||||
|
bun add react@next
|
||||||
|
|
||||||
|
# From git
|
||||||
|
bun add github:user/repo
|
||||||
|
bun add git+https://github.com/user/repo.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Removing & Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove package
|
||||||
|
bun remove lodash
|
||||||
|
|
||||||
|
# Update packages
|
||||||
|
bun update # Update all
|
||||||
|
bun update lodash # Update specific
|
||||||
|
bun update --latest # Update to latest (ignore ranges)
|
||||||
|
|
||||||
|
# Check outdated
|
||||||
|
bun outdated
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 bunx (npx equivalent)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Execute package binaries
|
||||||
|
bunx prettier --write .
|
||||||
|
bunx tsc --init
|
||||||
|
bunx create-react-app my-app
|
||||||
|
|
||||||
|
# With specific version
|
||||||
|
bunx -p typescript@4.9 tsc --version
|
||||||
|
|
||||||
|
# Run without installing
|
||||||
|
bunx cowsay "Hello from Bun!"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Lockfile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# bun.lockb is a binary lockfile (faster parsing)
|
||||||
|
# To generate text lockfile for debugging:
|
||||||
|
bun install --yarn # Creates yarn.lock
|
||||||
|
|
||||||
|
# Trust existing lockfile
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Running Code
|
||||||
|
|
||||||
|
### 4.1 Basic Execution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run TypeScript directly (no build step!)
|
||||||
|
bun run index.ts
|
||||||
|
|
||||||
|
# Run JavaScript
|
||||||
|
bun run index.js
|
||||||
|
|
||||||
|
# Run with arguments
|
||||||
|
bun run server.ts --port 3000
|
||||||
|
|
||||||
|
# Run package.json script
|
||||||
|
bun run dev
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Short form (for scripts)
|
||||||
|
bun dev
|
||||||
|
bun build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Watch Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auto-restart on file changes
|
||||||
|
bun --watch run index.ts
|
||||||
|
|
||||||
|
# With hot reloading
|
||||||
|
bun --hot run server.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Environment Variables
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// .env file is loaded automatically!
|
||||||
|
|
||||||
|
// Access environment variables
|
||||||
|
const apiKey = Bun.env.API_KEY;
|
||||||
|
const port = Bun.env.PORT ?? "3000";
|
||||||
|
|
||||||
|
// Or use process.env (Node.js compatible)
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with specific env file
|
||||||
|
bun --env-file=.env.production run index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Built-in APIs
|
||||||
|
|
||||||
|
### 5.1 File System (Bun.file)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Read file
|
||||||
|
const file = Bun.file("./data.json");
|
||||||
|
const text = await file.text();
|
||||||
|
const json = await file.json();
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
|
||||||
|
// File info
|
||||||
|
console.log(file.size); // bytes
|
||||||
|
console.log(file.type); // MIME type
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
await Bun.write("./output.txt", "Hello, Bun!");
|
||||||
|
await Bun.write("./data.json", JSON.stringify({ foo: "bar" }));
|
||||||
|
|
||||||
|
// Stream large files
|
||||||
|
const reader = file.stream();
|
||||||
|
for await (const chunk of reader) {
|
||||||
|
console.log(chunk);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 HTTP Server (Bun.serve)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 3000,
|
||||||
|
|
||||||
|
fetch(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/") {
|
||||||
|
return new Response("Hello World!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/users") {
|
||||||
|
return Response.json([
|
||||||
|
{ id: 1, name: "Alice" },
|
||||||
|
{ id: 2, name: "Bob" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
},
|
||||||
|
|
||||||
|
error(error) {
|
||||||
|
return new Response(`Error: ${error.message}`, { status: 500 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Server running at http://localhost:${server.port}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 WebSocket Server
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 3000,
|
||||||
|
|
||||||
|
fetch(req, server) {
|
||||||
|
// Upgrade to WebSocket
|
||||||
|
if (server.upgrade(req)) {
|
||||||
|
return; // Upgraded
|
||||||
|
}
|
||||||
|
return new Response("Upgrade failed", { status: 500 });
|
||||||
|
},
|
||||||
|
|
||||||
|
websocket: {
|
||||||
|
open(ws) {
|
||||||
|
console.log("Client connected");
|
||||||
|
ws.send("Welcome!");
|
||||||
|
},
|
||||||
|
|
||||||
|
message(ws, message) {
|
||||||
|
console.log(`Received: ${message}`);
|
||||||
|
ws.send(`Echo: ${message}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
close(ws) {
|
||||||
|
console.log("Client disconnected");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 SQLite (Bun.sql)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
|
||||||
|
const db = new Database("mydb.sqlite");
|
||||||
|
|
||||||
|
// Create table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT UNIQUE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Insert
|
||||||
|
const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
|
||||||
|
insert.run("Alice", "alice@example.com");
|
||||||
|
|
||||||
|
// Query
|
||||||
|
const query = db.prepare("SELECT * FROM users WHERE name = ?");
|
||||||
|
const user = query.get("Alice");
|
||||||
|
console.log(user); // { id: 1, name: "Alice", email: "alice@example.com" }
|
||||||
|
|
||||||
|
// Query all
|
||||||
|
const allUsers = db.query("SELECT * FROM users").all();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Password Hashing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Hash password
|
||||||
|
const password = crypto.randomUUID();
|
||||||
|
const hash = await Bun.password.hash(password);
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValid = await Bun.password.verify(password, hash);
|
||||||
|
console.log(isValid); // true
|
||||||
|
|
||||||
|
// With algorithm options
|
||||||
|
const bcryptHash = await Bun.password.hash(password, {
|
||||||
|
algorithm: "bcrypt",
|
||||||
|
cost: 12,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
### 6.1 Basic Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// math.test.ts
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
|
||||||
|
describe("Math operations", () => {
|
||||||
|
it("adds two numbers", () => {
|
||||||
|
expect(1 + 1).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts two numbers", () => {
|
||||||
|
expect(5 - 3).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# Run specific file
|
||||||
|
bun test math.test.ts
|
||||||
|
|
||||||
|
# Run matching pattern
|
||||||
|
bun test --grep "adds"
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
bun test --watch
|
||||||
|
|
||||||
|
# With coverage
|
||||||
|
bun test --coverage
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
bun test --timeout 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Matchers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
|
||||||
|
test("matchers", () => {
|
||||||
|
// Equality
|
||||||
|
expect(1).toBe(1);
|
||||||
|
expect({ a: 1 }).toEqual({ a: 1 });
|
||||||
|
expect([1, 2]).toContain(1);
|
||||||
|
|
||||||
|
// Comparisons
|
||||||
|
expect(10).toBeGreaterThan(5);
|
||||||
|
expect(5).toBeLessThanOrEqual(5);
|
||||||
|
|
||||||
|
// Truthiness
|
||||||
|
expect(true).toBeTruthy();
|
||||||
|
expect(null).toBeNull();
|
||||||
|
expect(undefined).toBeUndefined();
|
||||||
|
|
||||||
|
// Strings
|
||||||
|
expect("hello").toMatch(/ell/);
|
||||||
|
expect("hello").toContain("ell");
|
||||||
|
|
||||||
|
// Arrays
|
||||||
|
expect([1, 2, 3]).toHaveLength(3);
|
||||||
|
|
||||||
|
// Exceptions
|
||||||
|
expect(() => {
|
||||||
|
throw new Error("fail");
|
||||||
|
}).toThrow("fail");
|
||||||
|
|
||||||
|
// Async
|
||||||
|
await expect(Promise.resolve(1)).resolves.toBe(1);
|
||||||
|
await expect(Promise.reject("err")).rejects.toBe("err");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Mocking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { mock, spyOn } from "bun:test";
|
||||||
|
|
||||||
|
// Mock function
|
||||||
|
const mockFn = mock((x: number) => x * 2);
|
||||||
|
mockFn(5);
|
||||||
|
expect(mockFn).toHaveBeenCalled();
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(5);
|
||||||
|
expect(mockFn.mock.results[0].value).toBe(10);
|
||||||
|
|
||||||
|
// Spy on method
|
||||||
|
const obj = {
|
||||||
|
method: () => "original",
|
||||||
|
};
|
||||||
|
const spy = spyOn(obj, "method").mockReturnValue("mocked");
|
||||||
|
expect(obj.method()).toBe("mocked");
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Bundling
|
||||||
|
|
||||||
|
### 7.1 Basic Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bundle for production
|
||||||
|
bun build ./src/index.ts --outdir ./dist
|
||||||
|
|
||||||
|
# With options
|
||||||
|
bun build ./src/index.ts \
|
||||||
|
--outdir ./dist \
|
||||||
|
--target browser \
|
||||||
|
--minify \
|
||||||
|
--sourcemap
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Build API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: ["./src/index.ts"],
|
||||||
|
outdir: "./dist",
|
||||||
|
target: "browser", // or "bun", "node"
|
||||||
|
minify: true,
|
||||||
|
sourcemap: "external",
|
||||||
|
splitting: true,
|
||||||
|
format: "esm",
|
||||||
|
|
||||||
|
// External packages (not bundled)
|
||||||
|
external: ["react", "react-dom"],
|
||||||
|
|
||||||
|
// Define globals
|
||||||
|
define: {
|
||||||
|
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Naming
|
||||||
|
naming: {
|
||||||
|
entry: "[name].[hash].js",
|
||||||
|
chunk: "chunks/[name].[hash].js",
|
||||||
|
asset: "assets/[name].[hash][ext]",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(result.logs);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Compile to Executable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create standalone executable
|
||||||
|
bun build ./src/cli.ts --compile --outfile myapp
|
||||||
|
|
||||||
|
# Cross-compile
|
||||||
|
bun build ./src/cli.ts --compile --target=bun-linux-x64 --outfile myapp-linux
|
||||||
|
bun build ./src/cli.ts --compile --target=bun-darwin-arm64 --outfile myapp-mac
|
||||||
|
|
||||||
|
# With embedded assets
|
||||||
|
bun build ./src/cli.ts --compile --outfile myapp --embed ./assets
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Migration from Node.js
|
||||||
|
|
||||||
|
### 8.1 Compatibility
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Most Node.js APIs work out of the box
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
// process is global
|
||||||
|
console.log(process.cwd());
|
||||||
|
console.log(process.env.HOME);
|
||||||
|
|
||||||
|
// Buffer is global
|
||||||
|
const buf = Buffer.from("hello");
|
||||||
|
|
||||||
|
// __dirname and __filename work
|
||||||
|
console.log(__dirname);
|
||||||
|
console.log(__filename);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Common Migration Steps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install Bun
|
||||||
|
brew install oven-sh/bun/bun
|
||||||
|
|
||||||
|
# 2. Replace package manager
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# 3. Update scripts in package.json
|
||||||
|
# "start": "node index.js" → "start": "bun run index.ts"
|
||||||
|
# "test": "jest" → "test": "bun test"
|
||||||
|
|
||||||
|
# 4. Add Bun types
|
||||||
|
bun add -d @types/bun
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Differences from Node.js
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Node.js specific (may not work)
|
||||||
|
require("module") // Use import instead
|
||||||
|
require.resolve("pkg") // Use import.meta.resolve
|
||||||
|
__non_webpack_require__ // Not supported
|
||||||
|
|
||||||
|
// ✅ Bun equivalents
|
||||||
|
import pkg from "pkg";
|
||||||
|
const resolved = import.meta.resolve("pkg");
|
||||||
|
Bun.resolveSync("pkg", process.cwd());
|
||||||
|
|
||||||
|
// ❌ These globals differ
|
||||||
|
process.hrtime() // Use Bun.nanoseconds()
|
||||||
|
setImmediate() // Use queueMicrotask()
|
||||||
|
|
||||||
|
// ✅ Bun-specific features
|
||||||
|
const file = Bun.file("./data.txt"); // Fast file API
|
||||||
|
Bun.serve({ port: 3000, fetch: ... }); // Fast HTTP server
|
||||||
|
Bun.password.hash(password); // Built-in hashing
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Performance Tips
|
||||||
|
|
||||||
|
### 9.1 Use Bun-native APIs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Slow (Node.js compat)
|
||||||
|
import fs from "fs/promises";
|
||||||
|
const content = await fs.readFile("./data.txt", "utf-8");
|
||||||
|
|
||||||
|
// Fast (Bun-native)
|
||||||
|
const file = Bun.file("./data.txt");
|
||||||
|
const content = await file.text();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Use Bun.serve for HTTP
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Don't: Express/Fastify (overhead)
|
||||||
|
import express from "express";
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Do: Bun.serve (native, 4-10x faster)
|
||||||
|
Bun.serve({
|
||||||
|
fetch(req) {
|
||||||
|
return new Response("Hello!");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or use Elysia (Bun-optimized framework)
|
||||||
|
import { Elysia } from "elysia";
|
||||||
|
new Elysia().get("/", () => "Hello!").listen(3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Bundle for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Always bundle and minify for production
|
||||||
|
bun build ./src/index.ts --outdir ./dist --minify --target node
|
||||||
|
|
||||||
|
# Then run the bundle
|
||||||
|
bun run ./dist/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
| :----------- | :----------------------------------------- |
|
||||||
|
| Init project | `bun init` |
|
||||||
|
| Install deps | `bun install` |
|
||||||
|
| Add package | `bun add <pkg>` |
|
||||||
|
| Run script | `bun run <script>` |
|
||||||
|
| Run file | `bun run file.ts` |
|
||||||
|
| Watch mode | `bun --watch run file.ts` |
|
||||||
|
| Run tests | `bun test` |
|
||||||
|
| Build | `bun build ./src/index.ts --outdir ./dist` |
|
||||||
|
| Execute pkg | `bunx <pkg>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Bun Documentation](https://bun.sh/docs)
|
||||||
|
- [Bun GitHub](https://github.com/oven-sh/bun)
|
||||||
|
- [Elysia Framework](https://elysiajs.com/)
|
||||||
|
- [Bun Discord](https://bun.sh/discord)
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
- Use this skill only when the task clearly matches the scope described above.
|
||||||
|
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
|
||||||
|
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
---
|
||||||
|
name: cloudflare
|
||||||
|
description: Comprehensive Cloudflare platform skill covering Workers, Pages, storage (KV, D1, R2), AI (Workers AI, Vectorize, Agents SDK), networking (Tunnel, Spectrum), security (WAF, DDoS), and infrastructure-as-code (Terraform, Pulumi). Use for any Cloudflare development task. Biases towards retrieval from Cloudflare docs over pre-trained knowledge.
|
||||||
|
references:
|
||||||
|
- workers
|
||||||
|
- pages
|
||||||
|
- d1
|
||||||
|
- durable-objects
|
||||||
|
- workers-ai
|
||||||
|
---
|
||||||
|
|
||||||
|
# Cloudflare Platform Skill
|
||||||
|
|
||||||
|
Consolidated skill for building on the Cloudflare platform. Use decision trees below to find the right product, then load detailed references.
|
||||||
|
|
||||||
|
Your knowledge of Cloudflare APIs, types, limits, and pricing may be outdated. **Prefer retrieval over pre-training** — the references in this skill are starting points, not source of truth.
|
||||||
|
|
||||||
|
## Retrieval Sources
|
||||||
|
|
||||||
|
Fetch the **latest** information before citing specific numbers, API signatures, or configuration options. Do not rely on baked-in knowledge or these reference files alone.
|
||||||
|
|
||||||
|
| Source | How to retrieve | Use for |
|
||||||
|
|--------|----------------|---------|
|
||||||
|
| Cloudflare docs | `cloudflare-docs` search tool or `https://developers.cloudflare.com/` | Limits, pricing, API reference, compatibility dates/flags |
|
||||||
|
| Workers types | `npm pack @cloudflare/workers-types` or check `node_modules` | Type signatures, binding shapes, handler types |
|
||||||
|
| Wrangler config schema | `node_modules/wrangler/config-schema.json` | Config fields, binding shapes, allowed values |
|
||||||
|
| Product changelogs | `https://developers.cloudflare.com/changelog/` | Recent changes to limits, features, deprecations |
|
||||||
|
|
||||||
|
When a reference file and the docs disagree, **trust the docs**. This is especially important for: numeric limits, pricing tiers, type signatures, and configuration options.
|
||||||
|
|
||||||
|
## Quick Decision Trees
|
||||||
|
|
||||||
|
### "I need to run code"
|
||||||
|
|
||||||
|
```
|
||||||
|
Need to run code?
|
||||||
|
├─ Serverless functions at the edge → workers/
|
||||||
|
├─ Full-stack web app with Git deploys → pages/
|
||||||
|
├─ Stateful coordination/real-time → durable-objects/
|
||||||
|
├─ Long-running multi-step jobs → workflows/
|
||||||
|
├─ Run containers → containers/
|
||||||
|
├─ Multi-tenant (customers deploy code) → workers-for-platforms/
|
||||||
|
├─ Scheduled tasks (cron) → cron-triggers/
|
||||||
|
├─ Lightweight edge logic (modify HTTP) → snippets/
|
||||||
|
├─ Process Worker execution events (logs/observability) → tail-workers/
|
||||||
|
└─ Optimize latency to backend infrastructure → smart-placement/
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I need to store data"
|
||||||
|
|
||||||
|
```
|
||||||
|
Need storage?
|
||||||
|
├─ Key-value (config, sessions, cache) → kv/
|
||||||
|
├─ Relational SQL → d1/ (SQLite) or hyperdrive/ (existing Postgres/MySQL)
|
||||||
|
├─ Object/file storage (S3-compatible) → r2/
|
||||||
|
├─ Message queue (async processing) → queues/
|
||||||
|
├─ Vector embeddings (AI/semantic search) → vectorize/
|
||||||
|
├─ Strongly-consistent per-entity state → durable-objects/ (DO storage)
|
||||||
|
├─ Secrets management → secrets-store/
|
||||||
|
├─ Streaming ETL to R2 → pipelines/
|
||||||
|
└─ Persistent cache (long-term retention) → cache-reserve/
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I need AI/ML"
|
||||||
|
|
||||||
|
```
|
||||||
|
Need AI?
|
||||||
|
├─ Run inference (LLMs, embeddings, images) → workers-ai/
|
||||||
|
├─ Vector database for RAG/search → vectorize/
|
||||||
|
├─ Build stateful AI agents → agents-sdk/
|
||||||
|
├─ Gateway for any AI provider (caching, routing) → ai-gateway/
|
||||||
|
└─ AI-powered search widget → ai-search/
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I need networking/connectivity"
|
||||||
|
|
||||||
|
```
|
||||||
|
Need networking?
|
||||||
|
├─ Expose local service to internet → tunnel/
|
||||||
|
├─ TCP/UDP proxy (non-HTTP) → spectrum/
|
||||||
|
├─ WebRTC TURN server → turn/
|
||||||
|
├─ Private network connectivity → network-interconnect/
|
||||||
|
├─ Optimize routing → argo-smart-routing/
|
||||||
|
├─ Optimize latency to backend (not user) → smart-placement/
|
||||||
|
└─ Real-time video/audio → realtimekit/ or realtime-sfu/
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I need security"
|
||||||
|
|
||||||
|
```
|
||||||
|
Need security?
|
||||||
|
├─ Web Application Firewall → waf/
|
||||||
|
├─ DDoS protection → ddos/
|
||||||
|
├─ Bot detection/management → bot-management/
|
||||||
|
├─ API protection → api-shield/
|
||||||
|
├─ CAPTCHA alternative → turnstile/
|
||||||
|
└─ Credential leak detection → waf/ (managed ruleset)
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I need media/content"
|
||||||
|
|
||||||
|
```
|
||||||
|
Need media?
|
||||||
|
├─ Image optimization/transformation → images/
|
||||||
|
├─ Video streaming/encoding → stream/
|
||||||
|
├─ Browser automation/screenshots → browser-rendering/
|
||||||
|
└─ Third-party script management → zaraz/
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I need analytics/metrics data"
|
||||||
|
|
||||||
|
```
|
||||||
|
Need analytics?
|
||||||
|
├─ Query across all Cloudflare products (HTTP, Workers, DNS, etc.) → graphql-api/
|
||||||
|
├─ Custom high-cardinality metrics from Workers → analytics-engine/
|
||||||
|
├─ Client-side (RUM) performance data → web-analytics/
|
||||||
|
├─ Workers Logs and real-time debugging → observability/
|
||||||
|
└─ Raw logs (Logpush to external tools) → Cloudflare docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I need infrastructure-as-code"
|
||||||
|
|
||||||
|
```
|
||||||
|
Need IaC? → pulumi/ (Pulumi), terraform/ (Terraform), or api/ (REST API)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Product Index
|
||||||
|
|
||||||
|
### Compute & Runtime
|
||||||
|
| Product | Reference |
|
||||||
|
|---------|-----------|
|
||||||
|
| Workers | `references/workers/` |
|
||||||
|
| Pages | `references/pages/` |
|
||||||
|
| Pages Functions | `references/pages-functions/` |
|
||||||
|
| Durable Objects | `references/durable-objects/` |
|
||||||
|
| Workflows | `references/workflows/` |
|
||||||
|
| Containers | `references/containers/` |
|
||||||
|
| Workers for Platforms | `references/workers-for-platforms/` |
|
||||||
|
| Cron Triggers | `references/cron-triggers/` |
|
||||||
|
| Tail Workers | `references/tail-workers/` |
|
||||||
|
| Snippets | `references/snippets/` |
|
||||||
|
| Smart Placement | `references/smart-placement/` |
|
||||||
|
|
||||||
|
### Storage & Data
|
||||||
|
| Product | Reference |
|
||||||
|
|---------|-----------|
|
||||||
|
| KV | `references/kv/` |
|
||||||
|
| D1 | `references/d1/` |
|
||||||
|
| R2 | `references/r2/` |
|
||||||
|
| Queues | `references/queues/` |
|
||||||
|
| Hyperdrive | `references/hyperdrive/` |
|
||||||
|
| DO Storage | `references/do-storage/` |
|
||||||
|
| Secrets Store | `references/secrets-store/` |
|
||||||
|
| Pipelines | `references/pipelines/` |
|
||||||
|
| R2 Data Catalog | `references/r2-data-catalog/` |
|
||||||
|
| R2 SQL | `references/r2-sql/` |
|
||||||
|
|
||||||
|
### AI & Machine Learning
|
||||||
|
| Product | Reference |
|
||||||
|
|---------|-----------|
|
||||||
|
| Workers AI | `references/workers-ai/` |
|
||||||
|
| Vectorize | `references/vectorize/` |
|
||||||
|
| Agents SDK | `references/agents-sdk/` |
|
||||||
|
| AI Gateway | `references/ai-gateway/` |
|
||||||
|
| AI Search | `references/ai-search/` |
|
||||||
|
|
||||||
|
### Networking & Connectivity
|
||||||
|
| Product | Reference |
|
||||||
|
|---------|-----------|
|
||||||
|
| Tunnel | `references/tunnel/` |
|
||||||
|
| Spectrum | `references/spectrum/` |
|
||||||
|
| TURN | `references/turn/` |
|
||||||
|
| Network Interconnect | `references/network-interconnect/` |
|
||||||
|
| Argo Smart Routing | `references/argo-smart-routing/` |
|
||||||
|
| Workers VPC | `references/workers-vpc/` |
|
||||||
|
|
||||||
|
### Security
|
||||||
|
| Product | Reference |
|
||||||
|
|---------|-----------|
|
||||||
|
| WAF | `references/waf/` |
|
||||||
|
| DDoS Protection | `references/ddos/` |
|
||||||
|
| Bot Management | `references/bot-management/` |
|
||||||
|
| API Shield | `references/api-shield/` |
|
||||||
|
| Turnstile | `references/turnstile/` |
|
||||||
|
|
||||||
|
### Media & Content
|
||||||
|
| Product | Reference |
|
||||||
|
|---------|-----------|
|
||||||
|
| Images | `references/images/` |
|
||||||
|
| Stream | `references/stream/` |
|
||||||
|
| Browser Rendering | `references/browser-rendering/` |
|
||||||
|
| Zaraz | `references/zaraz/` |
|
||||||
|
|
||||||
|
### Real-Time Communication
|
||||||
|
| Product | Reference |
|
||||||
|
|---------|-----------|
|
||||||
|
| RealtimeKit | `references/realtimekit/` |
|
||||||
|
| Realtime SFU | `references/realtime-sfu/` |
|
||||||
|
|
||||||
|
### Developer Tools
|
||||||
|
| Product | Reference |
|
||||||
|
|---------|-----------|
|
||||||
|
| Wrangler | `references/wrangler/` |
|
||||||
|
| Miniflare | `references/miniflare/` |
|
||||||
|
| C3 | `references/c3/` |
|
||||||
|
| Observability | `references/observability/` |
|
||||||
|
| GraphQL Analytics API | `references/graphql-api/` |
|
||||||
|
| Analytics Engine | `references/analytics-engine/` |
|
||||||
|
| Web Analytics | `references/web-analytics/` |
|
||||||
|
| Sandbox | `references/sandbox/` |
|
||||||
|
| Workerd | `references/workerd/` |
|
||||||
|
| Workers Playground | `references/workers-playground/` |
|
||||||
|
|
||||||
|
### Infrastructure as Code
|
||||||
|
| Product | Reference |
|
||||||
|
|---------|-----------|
|
||||||
|
| Pulumi | `references/pulumi/` |
|
||||||
|
| Terraform | `references/terraform/` |
|
||||||
|
| API | `references/api/` |
|
||||||
|
|
||||||
|
### Other Services
|
||||||
|
| Product | Reference |
|
||||||
|
|---------|-----------|
|
||||||
|
| Email Routing | `references/email-routing/` |
|
||||||
|
| Email Workers | `references/email-workers/` |
|
||||||
|
| Static Assets | `references/static-assets/` |
|
||||||
|
| Bindings | `references/bindings/` |
|
||||||
|
| Cache Reserve | `references/cache-reserve/` |
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Cloudflare Agents SDK
|
||||||
|
|
||||||
|
Cloudflare Agents SDK enables building AI-powered agents on Durable Objects with state, WebSockets, SQL, scheduling, and AI integration.
|
||||||
|
|
||||||
|
## Core Value
|
||||||
|
Build stateful, globally distributed AI agents with persistent memory, real-time connections, scheduled tasks, and async workflows.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
- Persistent state + memory required
|
||||||
|
- Real-time WebSocket connections
|
||||||
|
- Long-running workflows (minutes/hours)
|
||||||
|
- Chat interfaces with AI models
|
||||||
|
- Scheduled/recurring tasks with state
|
||||||
|
- DB queries with agent state
|
||||||
|
|
||||||
|
## What Type of Agent?
|
||||||
|
|
||||||
|
| Use Case | Class | Key Features |
|
||||||
|
|----------|-------|--------------|
|
||||||
|
| AI chat interface | `AIChatAgent` | Auto-streaming, tools, message history, resumable |
|
||||||
|
| MCP tool provider | `Agent` + MCP | Expose tools to AI systems |
|
||||||
|
| Custom logic/routing | `Agent` | Full control, WebSockets, email, SQL |
|
||||||
|
| Real-time collaboration | `Agent` | WebSocket state, broadcasts |
|
||||||
|
| Email processing | `Agent` | `onEmail()` handler |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
**AI Chat Agent:**
|
||||||
|
```typescript
|
||||||
|
import { AIChatAgent } from "agents";
|
||||||
|
import { openai } from "@ai-sdk/openai";
|
||||||
|
|
||||||
|
export class ChatAgent extends AIChatAgent<Env> {
|
||||||
|
async onChatMessage(onFinish) {
|
||||||
|
return this.streamText({
|
||||||
|
model: openai("gpt-4"),
|
||||||
|
messages: this.messages,
|
||||||
|
onFinish,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Base Agent:**
|
||||||
|
```typescript
|
||||||
|
import { Agent } from "agents";
|
||||||
|
|
||||||
|
export class MyAgent extends Agent<Env> {
|
||||||
|
onStart() {
|
||||||
|
this.sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onRequest(request: Request) {
|
||||||
|
return Response.json({ state: this.state });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
| Task | Files to Read |
|
||||||
|
|------|---------------|
|
||||||
|
| Quick start | README only |
|
||||||
|
| Build chat agent | README → api.md (AIChatAgent) → patterns.md |
|
||||||
|
| Setup project | README → configuration.md |
|
||||||
|
| Add React frontend | README → api.md (Client Hooks) → patterns.md |
|
||||||
|
| Build MCP server | api.md (MCP) → patterns.md |
|
||||||
|
| Background tasks | api.md (Scheduling, Task Queue) → patterns.md |
|
||||||
|
| Debug issues | gotchas.md |
|
||||||
|
|
||||||
|
## Package Entry Points
|
||||||
|
|
||||||
|
| Import | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `agents` | Server-side Agent classes, lifecycle |
|
||||||
|
| `agents/react` | `useAgent()` hook for WebSocket connections |
|
||||||
|
| `agents/ai-react` | `useAgentChat()` hook for AI chat UIs |
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
- [configuration.md](./configuration.md) - SDK setup, wrangler config, routing
|
||||||
|
- [api.md](./api.md) - Agent classes, lifecycle, client hooks
|
||||||
|
- [patterns.md](./patterns.md) - Common workflows, best practices
|
||||||
|
- [gotchas.md](./gotchas.md) - Common issues, limits
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
- durable-objects - Agent infrastructure
|
||||||
|
- d1 - External database integration
|
||||||
|
- workers-ai - AI model integration
|
||||||
|
- vectorize - Vector search for RAG patterns
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
## Agent Classes
|
||||||
|
|
||||||
|
### AIChatAgent
|
||||||
|
|
||||||
|
For AI chat with auto-streaming, message history, tools, resumable streaming.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { AIChatAgent } from "agents";
|
||||||
|
import { openai } from "@ai-sdk/openai";
|
||||||
|
|
||||||
|
export class ChatAgent extends AIChatAgent<Env> {
|
||||||
|
async onChatMessage(onFinish) {
|
||||||
|
return this.streamText({
|
||||||
|
model: openai("gpt-4"),
|
||||||
|
messages: this.messages, // Auto-managed message history
|
||||||
|
tools: {
|
||||||
|
getWeather: {
|
||||||
|
description: "Get weather",
|
||||||
|
parameters: z.object({ city: z.string() }),
|
||||||
|
execute: async ({ city }) => `Sunny, 72°F in ${city}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinish, // Persist response to this.messages
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent (Base Class)
|
||||||
|
|
||||||
|
Full control for custom logic, WebSockets, email, and SQL.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Agent } from "agents";
|
||||||
|
|
||||||
|
export class MyAgent extends Agent<Env, State> {
|
||||||
|
// Lifecycle methods below
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Type params:** `Agent<Env, State, ConnState>` - Env bindings, agent state, connection state
|
||||||
|
|
||||||
|
## Lifecycle Hooks
|
||||||
|
|
||||||
|
```ts
|
||||||
|
onStart() { // Init/restart
|
||||||
|
this.sql`CREATE TABLE IF NOT EXISTS users (id TEXT, name TEXT)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onRequest(req: Request) { // HTTP
|
||||||
|
const {pathname} = new URL(req.url);
|
||||||
|
if (pathname === "/users") return Response.json(this.sql<{id,name}>`SELECT * FROM users`);
|
||||||
|
return new Response("Not found", {status: 404});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onConnect(conn: Connection<ConnState>, ctx: ConnectionContext) { // WebSocket
|
||||||
|
conn.accept();
|
||||||
|
conn.setState({userId: ctx.request.headers.get("X-User-ID")});
|
||||||
|
conn.send(JSON.stringify({type: "connected", state: this.state}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onMessage(conn: Connection<ConnState>, msg: WSMessage) { // WS messages
|
||||||
|
const m = JSON.parse(msg as string);
|
||||||
|
this.setState({messages: [...this.state.messages, m]});
|
||||||
|
this.connections.forEach(c => c.send(JSON.stringify(m)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onEmail(email: AgentEmail) { // Email routing
|
||||||
|
this.sql`INSERT INTO emails (from_addr,subject,body) VALUES (${email.from},${email.headers.get("subject")},${await email.text()})`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State, SQL, Scheduling
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// State
|
||||||
|
this.setState({count: 42}); // Auto-syncs
|
||||||
|
this.setState({...this.state, count: this.state.count + 1});
|
||||||
|
|
||||||
|
// SQL (parameterized queries prevent injection)
|
||||||
|
this.sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT)`;
|
||||||
|
this.sql`INSERT INTO users (id,name) VALUES (${userId},${name})`;
|
||||||
|
const users = this.sql<{id,name}>`SELECT * FROM users WHERE id = ${userId}`;
|
||||||
|
|
||||||
|
// Scheduling
|
||||||
|
await this.schedule(new Date("2026-12-25"), "sendGreeting", {msg:"Hi"}); // Date
|
||||||
|
await this.schedule(60, "checkStatus", {}); // Delay (sec)
|
||||||
|
await this.schedule("0 0 * * *", "dailyCleanup", {}); // Cron
|
||||||
|
await this.cancelSchedule(scheduleId);
|
||||||
|
```
|
||||||
|
|
||||||
|
## RPC Methods (@callable)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Agent, callable } from "agents";
|
||||||
|
|
||||||
|
export class MyAgent extends Agent<Env> {
|
||||||
|
@callable()
|
||||||
|
async processTask(input: {text: string}): Promise<{result: string}> {
|
||||||
|
return { result: await this.env.AI.run("@cf/meta/llama-3.1-8b-instruct", {prompt: input.text}) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Client: const result = await agent.processTask({ text: "Hello" });
|
||||||
|
// Must return JSON-serializable values
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connections & AI
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Connections (type: Agent<Env, State, ConnState>)
|
||||||
|
this.connections.forEach(c => c.send(JSON.stringify(msg))); // Broadcast
|
||||||
|
conn.setState({userId:"123"}); conn.close(1000, "Goodbye");
|
||||||
|
|
||||||
|
// Workers AI
|
||||||
|
const r = await this.env.AI.run("@cf/meta/llama-3.1-8b-instruct", {prompt});
|
||||||
|
|
||||||
|
// Manual streaming (prefer AIChatAgent)
|
||||||
|
const stream = await client.chat.completions.create({model: "gpt-4", messages, stream: true});
|
||||||
|
for await (const chunk of stream) conn.send(JSON.stringify({chunk: chunk.choices[0].delta.content}));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Type-safe state:** `Agent<Env, State, ConnState>` - third param types `conn.state`
|
||||||
|
|
||||||
|
## MCP Integration
|
||||||
|
|
||||||
|
Model Context Protocol for exposing tools:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Register & use MCP server
|
||||||
|
await this.mcp.registerServer("github", {
|
||||||
|
url: env.MCP_SERVER_URL,
|
||||||
|
auth: { type: "oauth", clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET }
|
||||||
|
});
|
||||||
|
const tools = await this.mcp.getAITools(["github"]);
|
||||||
|
return this.streamText({ model: openai("gpt-4"), messages: this.messages, tools, onFinish });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Queue
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await this.queue("processVideo", { videoId: "abc123" }); // Add task
|
||||||
|
const tasks = await this.dequeue(10); // Process up to 10
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context & Cleanup
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const agent = getCurrentAgent<MyAgent>(); // Get current instance
|
||||||
|
async destroy() { /* cleanup before agent destroyed */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
## AI Integration
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Workers AI
|
||||||
|
const r = await this.env.AI.run("@cf/meta/llama-3.1-8b-instruct", {prompt});
|
||||||
|
|
||||||
|
// Manual streaming (prefer AIChatAgent for auto-streaming)
|
||||||
|
const stream = await client.chat.completions.create({model: "gpt-4", messages, stream: true});
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (chunk.choices[0]?.delta?.content) conn.send(JSON.stringify({chunk: chunk.choices[0].delta.content}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Hooks (React)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// useAgent() - WebSocket connection + RPC
|
||||||
|
import { useAgent } from "agents/react";
|
||||||
|
const agent = useAgent({ agent: "MyAgent", name: "user-123" }); // name for idFromName
|
||||||
|
const result = await agent.processTask({ text: "Hello" }); // Call @callable methods
|
||||||
|
// agent.readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
|
||||||
|
|
||||||
|
// useAgentChat() - AI chat UI
|
||||||
|
import { useAgentChat } from "agents/ai-react";
|
||||||
|
const agent = useAgent({ agent: "ChatAgent" });
|
||||||
|
const { messages, input, handleInputChange, handleSubmit, isLoading, stop, clearHistory } =
|
||||||
|
useAgentChat({
|
||||||
|
agent,
|
||||||
|
maxSteps: 5, // Max tool iterations
|
||||||
|
resume: true, // Auto-resume on disconnect
|
||||||
|
onToolCall: async (toolCall) => {
|
||||||
|
// Client tools (human-in-the-loop)
|
||||||
|
if (toolCall.toolName === "confirm") return { ok: window.confirm("Proceed?") };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// status: "ready" | "submitted" | "streaming" | "error"
|
||||||
|
```
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
## Wrangler Setup
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "my-agents-app",
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{"name": "MyAgent", "class_name": "MyAgent"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{"tag": "v1", "new_sqlite_classes": ["MyAgent"]}
|
||||||
|
],
|
||||||
|
"ai": {
|
||||||
|
"binding": "AI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Bindings
|
||||||
|
|
||||||
|
**Type-safe pattern:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Env {
|
||||||
|
AI?: Ai; // Workers AI
|
||||||
|
MyAgent?: DurableObjectNamespace<MyAgent>;
|
||||||
|
ChatAgent?: DurableObjectNamespace<ChatAgent>;
|
||||||
|
DB?: D1Database; // D1 database
|
||||||
|
KV?: KVNamespace; // KV storage
|
||||||
|
R2?: R2Bucket; // R2 bucket
|
||||||
|
OPENAI_API_KEY?: string; // Secrets
|
||||||
|
GITHUB_CLIENT_ID?: string; // MCP OAuth credentials
|
||||||
|
GITHUB_CLIENT_SECRET?: string;
|
||||||
|
QUEUE?: Queue; // Queues
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best practice:** Define all DO bindings in Env interface for type safety.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local dev
|
||||||
|
npx wrangler dev
|
||||||
|
|
||||||
|
# Deploy production
|
||||||
|
npx wrangler deploy
|
||||||
|
|
||||||
|
# Set secrets
|
||||||
|
npx wrangler secret put OPENAI_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent Routing
|
||||||
|
|
||||||
|
**Recommended: Use route helpers**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { routeAgent } from "agents";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetch(request: Request, env: Env) {
|
||||||
|
return routeAgent(request, env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Helper routes requests to agents automatically based on URL patterns.
|
||||||
|
|
||||||
|
**Manual routing (advanced):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Named ID (deterministic)
|
||||||
|
const id = env.MyAgent.idFromName("user-123");
|
||||||
|
|
||||||
|
// Random ID (from URL param)
|
||||||
|
// const id = env.MyAgent.idFromString(url.searchParams.get("id"));
|
||||||
|
|
||||||
|
const stub = env.MyAgent.get(id);
|
||||||
|
return stub.fetch(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-agent setup:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { routeAgent } from "agents";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetch(request: Request, env: Env) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Route by path
|
||||||
|
if (url.pathname.startsWith("/chat")) {
|
||||||
|
return routeAgent(request, env, "ChatAgent");
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith("/task")) {
|
||||||
|
return routeAgent(request, env, "TaskAgent");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Email Routing
|
||||||
|
|
||||||
|
**Code setup:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { routeAgentEmail } from "agents";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetch: (req: Request, env: Env) => routeAgent(req, env),
|
||||||
|
email: (message: ForwardableEmailMessage, env: Env) => {
|
||||||
|
return routeAgentEmail(message, env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dashboard setup:**
|
||||||
|
|
||||||
|
Configure email routing in Cloudflare dashboard:
|
||||||
|
|
||||||
|
```
|
||||||
|
Destination: Workers with Durable Objects
|
||||||
|
Worker: my-agents-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Then handle in agent:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class EmailAgent extends Agent<Env> {
|
||||||
|
async onEmail(email: AgentEmail) {
|
||||||
|
const text = await email.text();
|
||||||
|
// Process email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## AI Gateway (Optional)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Enable caching/routing through AI Gateway
|
||||||
|
const response = await this.env.AI.run(
|
||||||
|
"@cf/meta/llama-3.1-8b-instruct",
|
||||||
|
{ prompt },
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
id: "my-gateway-id",
|
||||||
|
skipCache: false,
|
||||||
|
cacheTtl: 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Configuration (Optional)
|
||||||
|
|
||||||
|
For exposing tools via Model Context Protocol:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// wrangler.jsonc - Add MCP OAuth secrets
|
||||||
|
{
|
||||||
|
"vars": {
|
||||||
|
"MCP_SERVER_URL": "https://mcp.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set secrets via CLI
|
||||||
|
// npx wrangler secret put GITHUB_CLIENT_ID
|
||||||
|
// npx wrangler secret put GITHUB_CLIENT_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
Then register in agent code (see api.md MCP section).
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Gotchas & Best Practices
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### "setState() not syncing"
|
||||||
|
|
||||||
|
**Cause:** Mutating state directly or not calling `setState()` after modifications
|
||||||
|
**Solution:** Always use `setState()` with immutable updates:
|
||||||
|
```ts
|
||||||
|
// ❌ this.state.count++
|
||||||
|
// ✅ this.setState({...this.state, count: this.state.count + 1})
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Message history grows unbounded (AIChatAgent)"
|
||||||
|
|
||||||
|
**Cause:** `this.messages` in `AIChatAgent` accumulates all messages indefinitely
|
||||||
|
**Solution:** Manually trim old messages periodically:
|
||||||
|
```ts
|
||||||
|
export class ChatAgent extends AIChatAgent<Env> {
|
||||||
|
async onChatMessage(onFinish) {
|
||||||
|
// Keep only last 50 messages
|
||||||
|
if (this.messages.length > 50) {
|
||||||
|
this.messages = this.messages.slice(-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.streamText({ model: openai("gpt-4"), messages: this.messages, onFinish });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### "SQL injection vulnerability"
|
||||||
|
|
||||||
|
**Cause:** Direct string interpolation in SQL queries
|
||||||
|
**Solution:** Use parameterized queries:
|
||||||
|
```ts
|
||||||
|
// ❌ this.sql`...WHERE id = '${userId}'`
|
||||||
|
// ✅ this.sql`...WHERE id = ${userId}`
|
||||||
|
```
|
||||||
|
|
||||||
|
### "WebSocket connection timeout"
|
||||||
|
|
||||||
|
**Cause:** Not calling `conn.accept()` in `onConnect`
|
||||||
|
**Solution:** Always accept connections:
|
||||||
|
```ts
|
||||||
|
async onConnect(conn: Connection, ctx: ConnectionContext) { conn.accept(); conn.setState({userId: "123"}); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Schedule limit exceeded"
|
||||||
|
|
||||||
|
**Cause:** More than 1000 scheduled tasks per agent
|
||||||
|
**Solution:** Clean up old schedules and limit creation rate:
|
||||||
|
```ts
|
||||||
|
async checkSchedules() { if ((await this.getSchedules()).length > 800) console.warn("Near limit!"); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### "AI Gateway unavailable"
|
||||||
|
|
||||||
|
**Cause:** AI service timeout or quota exceeded
|
||||||
|
**Solution:** Add error handling and fallbacks:
|
||||||
|
```ts
|
||||||
|
try {
|
||||||
|
return await this.env.AI.run(model, {prompt});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("AI error:", e);
|
||||||
|
return {error: "Unavailable"};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### "@callable method returns undefined"
|
||||||
|
|
||||||
|
**Cause:** Method doesn't return JSON-serializable value, or has non-serializable types
|
||||||
|
**Solution:** Ensure return values are plain objects/arrays/primitives:
|
||||||
|
```ts
|
||||||
|
// ❌ Returns class instance
|
||||||
|
@callable()
|
||||||
|
async getData() { return new Date(); }
|
||||||
|
|
||||||
|
// ✅ Returns serializable object
|
||||||
|
@callable()
|
||||||
|
async getData() { return { timestamp: Date.now() }; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Resumable stream not resuming"
|
||||||
|
|
||||||
|
**Cause:** Stream ID must be deterministic for resumption to work
|
||||||
|
**Solution:** Use AIChatAgent (automatic) or ensure consistent stream IDs:
|
||||||
|
```ts
|
||||||
|
// AIChatAgent handles this automatically
|
||||||
|
export class ChatAgent extends AIChatAgent<Env> {
|
||||||
|
// Resumption works out of the box
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### "MCP connection loss on hibernation"
|
||||||
|
|
||||||
|
**Cause:** MCP server connections don't survive hibernation
|
||||||
|
**Solution:** Re-register servers in `onStart()` or check connection status:
|
||||||
|
```ts
|
||||||
|
onStart() {
|
||||||
|
// Re-register MCP servers after hibernation
|
||||||
|
await this.mcp.registerServer("github", { url: env.MCP_URL, auth: {...} });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Agent not found"
|
||||||
|
|
||||||
|
**Cause:** Durable Object binding missing or incorrect class name
|
||||||
|
**Solution:** Verify DO binding in wrangler.jsonc and class name matches
|
||||||
|
|
||||||
|
## Rate Limits & Quotas
|
||||||
|
|
||||||
|
| Resource/Limit | Value | Notes |
|
||||||
|
|----------------|-------|-------|
|
||||||
|
| CPU per request | 30s (std), 300s (max) | Set in wrangler.jsonc |
|
||||||
|
| Memory per instance | 128MB | Shared with WebSockets |
|
||||||
|
| Storage per agent | 10GB | SQLite storage |
|
||||||
|
| Scheduled tasks | 1000 per agent | Monitor with `getSchedules()` |
|
||||||
|
| WebSocket connections | Unlimited | Within memory limits |
|
||||||
|
| SQL columns | 100 | Per table |
|
||||||
|
| SQL row size | 2MB | Key + value |
|
||||||
|
| WebSocket message | 32MiB | Max size |
|
||||||
|
| DO requests/sec | ~1000 | Per unique DO instance; rate limit if needed |
|
||||||
|
| AI Gateway (Workers AI) | Model-specific | Check dashboard for limits |
|
||||||
|
| MCP requests | Depends on server | Implement retry/backoff |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- Use immutable updates: `setState({...this.state, key: newValue})`
|
||||||
|
- Trim unbounded arrays (messages, logs) periodically
|
||||||
|
- Store large data in SQL, not state
|
||||||
|
|
||||||
|
### SQL Usage
|
||||||
|
- Create tables in `onStart()`, not `onRequest()`
|
||||||
|
- Use parameterized queries: `` sql`WHERE id = ${id}` `` (NOT `` sql`WHERE id = '${id}'` ``)
|
||||||
|
- Index frequently queried columns
|
||||||
|
|
||||||
|
### Scheduling
|
||||||
|
- Monitor schedule count: `await this.getSchedules()`
|
||||||
|
- Cancel completed tasks to stay under 1000 limit
|
||||||
|
- Use cron strings for recurring tasks
|
||||||
|
|
||||||
|
### WebSockets
|
||||||
|
- Always call `conn.accept()` in `onConnect()`
|
||||||
|
- Handle client disconnects gracefully
|
||||||
|
- Broadcast to `this.connections` efficiently
|
||||||
|
|
||||||
|
### AI Integration
|
||||||
|
- Use `AIChatAgent` for chat interfaces (auto-streaming, resumption)
|
||||||
|
- Trim message history to avoid token limits
|
||||||
|
- Handle AI errors with try/catch and fallbacks
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
- **Rate limiting:** Implement request throttling for high-traffic agents (>1000 req/s)
|
||||||
|
- **Monitoring:** Log critical errors, track schedule count, monitor storage usage
|
||||||
|
- **Graceful degradation:** Handle AI service outages with fallbacks
|
||||||
|
- **Message trimming:** Enforce max history length (e.g., 100 messages) in AIChatAgent
|
||||||
|
- **MCP reliability:** Re-register servers on hibernation, implement retry logic
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Patterns & Use Cases
|
||||||
|
|
||||||
|
## AI Chat w/Tools
|
||||||
|
|
||||||
|
**Server (AIChatAgent):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { AIChatAgent } from "agents";
|
||||||
|
import { openai } from "@ai-sdk/openai";
|
||||||
|
import { tool } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export class ChatAgent extends AIChatAgent<Env> {
|
||||||
|
async onChatMessage(onFinish) {
|
||||||
|
return this.streamText({
|
||||||
|
model: openai("gpt-4"),
|
||||||
|
messages: this.messages, // Auto-managed
|
||||||
|
tools: {
|
||||||
|
getWeather: tool({
|
||||||
|
description: "Get current weather",
|
||||||
|
parameters: z.object({ city: z.string() }),
|
||||||
|
execute: async ({ city }) => `Weather in ${city}: Sunny, 72°F`
|
||||||
|
}),
|
||||||
|
searchDocs: tool({
|
||||||
|
description: "Search documentation",
|
||||||
|
parameters: z.object({ query: z.string() }),
|
||||||
|
execute: async ({ query }) => JSON.stringify(
|
||||||
|
this.sql<{title, content}>`SELECT title, content FROM docs WHERE content LIKE ${'%' + query + '%'}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onFinish,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client (React):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAgent } from "agents/react";
|
||||||
|
import { useAgentChat } from "agents/ai-react";
|
||||||
|
|
||||||
|
function ChatUI() {
|
||||||
|
const agent = useAgent({ agent: "ChatAgent" });
|
||||||
|
const { messages, input, handleInputChange, handleSubmit, isLoading } = useAgentChat({ agent });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{messages.map(m => <div key={m.id}>{m.role}: {m.content}</div>)}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input value={input} onChange={handleInputChange} disabled={isLoading} />
|
||||||
|
<button disabled={isLoading}>Send</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Human-in-the-Loop (Client Tools)
|
||||||
|
|
||||||
|
Server defines tool, client executes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Server
|
||||||
|
export class ChatAgent extends AIChatAgent<Env> {
|
||||||
|
async onChatMessage(onFinish) {
|
||||||
|
return this.streamText({
|
||||||
|
model: openai("gpt-4"),
|
||||||
|
messages: this.messages,
|
||||||
|
tools: {
|
||||||
|
confirmAction: tool({
|
||||||
|
description: "Ask user to confirm",
|
||||||
|
parameters: z.object({ action: z.string() }),
|
||||||
|
execute: "client", // Client-side execution
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onFinish,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client
|
||||||
|
const { messages } = useAgentChat({
|
||||||
|
agent,
|
||||||
|
onToolCall: async (toolCall) => {
|
||||||
|
if (toolCall.toolName === "confirmAction") {
|
||||||
|
return { confirmed: window.confirm(`Confirm: ${toolCall.args.action}?`) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Queue & Scheduled Processing
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class TaskAgent extends Agent<Env> {
|
||||||
|
onStart() {
|
||||||
|
this.schedule("*/5 * * * *", "processQueue", {}); // Every 5 min
|
||||||
|
this.schedule("0 0 * * *", "dailyCleanup", {}); // Daily
|
||||||
|
}
|
||||||
|
|
||||||
|
async onRequest(req: Request) {
|
||||||
|
await this.queue("processVideo", { videoId: (await req.json()).videoId });
|
||||||
|
return Response.json({ queued: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async processQueue() {
|
||||||
|
const tasks = await this.dequeue(10);
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.name === "processVideo") await this.processVideo(task.data.videoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async dailyCleanup() {
|
||||||
|
this.sql`DELETE FROM logs WHERE created_at < ${Date.now() - 86400000}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual WebSocket Chat
|
||||||
|
|
||||||
|
Custom protocols (non-AI):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class ChatAgent extends Agent<Env> {
|
||||||
|
async onConnect(conn: Connection, ctx: ConnectionContext) {
|
||||||
|
conn.accept();
|
||||||
|
conn.setState({userId: ctx.request.headers.get("X-User-ID") || "anon"});
|
||||||
|
conn.send(JSON.stringify({type: "history", messages: this.state.messages}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onMessage(conn: Connection, msg: WSMessage) {
|
||||||
|
const newMsg = {userId: conn.state.userId, text: JSON.parse(msg as string).text, timestamp: Date.now()};
|
||||||
|
this.setState({messages: [...this.state.messages, newMsg]});
|
||||||
|
this.connections.forEach(c => c.send(JSON.stringify(newMsg)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Email Processing w/AI
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class EmailAgent extends Agent<Env> {
|
||||||
|
async onEmail(email: AgentEmail) {
|
||||||
|
const [text, from, subject] = [await email.text(), email.from, email.headers.get("subject") || ""];
|
||||||
|
this.sql`INSERT INTO emails (from_addr, subject, body) VALUES (${from}, ${subject}, ${text})`;
|
||||||
|
|
||||||
|
const { text: summary } = await generateText({
|
||||||
|
model: openai("gpt-4o-mini"), prompt: `Summarize: ${subject}\n\n${text}`
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connections.forEach(c => c.send(JSON.stringify({type: "new_email", from, summary})));
|
||||||
|
if (summary.includes("urgent")) await this.schedule(0, "sendAutoReply", { to: from });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Real-time Collaboration
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class GameAgent extends Agent<Env> {
|
||||||
|
initialState = { players: [], gameStarted: false };
|
||||||
|
|
||||||
|
async onConnect(conn: Connection, ctx: ConnectionContext) {
|
||||||
|
conn.accept();
|
||||||
|
const playerId = ctx.request.headers.get("X-Player-ID") || crypto.randomUUID();
|
||||||
|
conn.setState({ playerId });
|
||||||
|
|
||||||
|
const newPlayer = { id: playerId, score: 0 };
|
||||||
|
this.setState({...this.state, players: [...this.state.players, newPlayer]});
|
||||||
|
this.connections.forEach(c => c.send(JSON.stringify({type: "player_joined", player: newPlayer})));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onMessage(conn: Connection, msg: WSMessage) {
|
||||||
|
const m = JSON.parse(msg as string);
|
||||||
|
|
||||||
|
if (m.type === "move") {
|
||||||
|
this.setState({
|
||||||
|
...this.state,
|
||||||
|
players: this.state.players.map(p => p.id === conn.state.playerId ? {...p, score: p.score + m.points} : p)
|
||||||
|
});
|
||||||
|
this.connections.forEach(c => c.send(JSON.stringify({type: "player_moved", playerId: conn.state.playerId})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.type === "start" && this.state.players.length >= 2) {
|
||||||
|
this.setState({...this.state, gameStarted: true});
|
||||||
|
this.connections.forEach(c => c.send(JSON.stringify({type: "game_started"})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Cloudflare AI Gateway
|
||||||
|
|
||||||
|
Expert guidance for implementing Cloudflare AI Gateway - a universal gateway for AI model providers with analytics, caching, rate limiting, and routing capabilities.
|
||||||
|
|
||||||
|
## When to Use This Reference
|
||||||
|
|
||||||
|
- Setting up AI Gateway for any AI provider (OpenAI, Anthropic, Workers AI, etc.)
|
||||||
|
- Implementing caching, rate limiting, or request retry/fallback
|
||||||
|
- Configuring dynamic routing with A/B testing or model fallbacks
|
||||||
|
- Managing provider API keys securely with BYOK
|
||||||
|
- Adding security features (guardrails, DLP)
|
||||||
|
- Setting up observability with logging and custom metadata
|
||||||
|
- Debugging AI Gateway requests or optimizing configurations
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
**What's your setup?**
|
||||||
|
|
||||||
|
- **Using Vercel AI SDK** → Pattern 1 (recommended) - see [sdk-integration.md](./sdk-integration.md)
|
||||||
|
- **Using OpenAI SDK** → Pattern 2 - see [sdk-integration.md](./sdk-integration.md)
|
||||||
|
- **Cloudflare Worker + Workers AI** → Pattern 3 - see [sdk-integration.md](./sdk-integration.md)
|
||||||
|
- **Direct HTTP (any language)** → Pattern 4 - see [configuration.md](./configuration.md)
|
||||||
|
- **Framework (LangChain, etc.)** → See [sdk-integration.md](./sdk-integration.md)
|
||||||
|
|
||||||
|
## Pattern 1: Vercel AI SDK (Recommended)
|
||||||
|
|
||||||
|
Most modern pattern using official `ai-gateway-provider` package with automatic fallbacks.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createAiGateway } from 'ai-gateway-provider';
|
||||||
|
import { createOpenAI } from '@ai-sdk/openai';
|
||||||
|
import { generateText } from 'ai';
|
||||||
|
|
||||||
|
const gateway = createAiGateway({
|
||||||
|
accountId: process.env.CF_ACCOUNT_ID,
|
||||||
|
gateway: process.env.CF_GATEWAY_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openai = createOpenAI({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY
|
||||||
|
});
|
||||||
|
|
||||||
|
// Single model
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: gateway(openai('gpt-4o')),
|
||||||
|
prompt: 'Hello'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatic fallback array
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: gateway([
|
||||||
|
openai('gpt-4o'), // Try first
|
||||||
|
anthropic('claude-sonnet-4-5'), // Fallback
|
||||||
|
]),
|
||||||
|
prompt: 'Hello'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Install:** `npm install ai-gateway-provider ai @ai-sdk/openai @ai-sdk/anthropic`
|
||||||
|
|
||||||
|
## Pattern 2: OpenAI SDK
|
||||||
|
|
||||||
|
Drop-in replacement for OpenAI API with multi-provider support.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
const client = new OpenAI({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/compat`,
|
||||||
|
defaultHeaders: {
|
||||||
|
'cf-aig-authorization': `Bearer ${cfToken}` // For authenticated gateways
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch providers by changing model format: {provider}/{model}
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: 'openai/gpt-4o', // or 'anthropic/claude-sonnet-4-5'
|
||||||
|
messages: [{ role: 'user', content: 'Hello!' }]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern 3: Workers AI Binding
|
||||||
|
|
||||||
|
For Cloudflare Workers using Workers AI.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request, env, ctx) {
|
||||||
|
const response = await env.AI.run(
|
||||||
|
'@cf/meta/llama-3-8b-instruct',
|
||||||
|
{ messages: [{ role: 'user', content: 'Hello!' }] },
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
id: 'my-gateway',
|
||||||
|
metadata: { userId: '123', team: 'engineering' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response.json(response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Headers Quick Reference
|
||||||
|
|
||||||
|
| Header | Purpose | Example | Notes |
|
||||||
|
|--------|---------|---------|-------|
|
||||||
|
| `cf-aig-authorization` | Gateway auth | `Bearer {token}` | Required for authenticated gateways |
|
||||||
|
| `cf-aig-metadata` | Tracking | `{"userId":"x"}` | Max 5 entries, flat structure |
|
||||||
|
| `cf-aig-cache-ttl` | Cache duration | `3600` | Seconds, min 60, max 2592000 (30 days) |
|
||||||
|
| `cf-aig-skip-cache` | Bypass cache | `true` | - |
|
||||||
|
| `cf-aig-cache-key` | Custom cache key | `my-key` | Must be unique per response |
|
||||||
|
| `cf-aig-collect-log` | Skip logging | `false` | Default: true |
|
||||||
|
| `cf-aig-cache-status` | Cache hit/miss | Response only | `HIT` or `MISS` |
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| [sdk-integration.md](./sdk-integration.md) | Vercel AI SDK, OpenAI SDK, Workers binding patterns |
|
||||||
|
| [configuration.md](./configuration.md) | Dashboard setup, wrangler, API tokens |
|
||||||
|
| [features.md](./features.md) | Caching, rate limits, guardrails, DLP, BYOK, unified billing |
|
||||||
|
| [dynamic-routing.md](./dynamic-routing.md) | Fallbacks, A/B testing, conditional routing |
|
||||||
|
| [troubleshooting.md](./troubleshooting.md) | Debugging, errors, observability, gotchas |
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
| Task | Files |
|
||||||
|
|------|-------|
|
||||||
|
| First-time setup | README + [configuration.md](./configuration.md) |
|
||||||
|
| SDK integration | README + [sdk-integration.md](./sdk-integration.md) |
|
||||||
|
| Enable caching | README + [features.md](./features.md) |
|
||||||
|
| Setup fallbacks | README + [dynamic-routing.md](./dynamic-routing.md) |
|
||||||
|
| Debug errors | README + [troubleshooting.md](./troubleshooting.md) |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
AI Gateway acts as a proxy between your application and AI providers:
|
||||||
|
|
||||||
|
```
|
||||||
|
Your App → AI Gateway → AI Provider (OpenAI, Anthropic, etc.)
|
||||||
|
↓
|
||||||
|
Analytics, Caching, Rate Limiting, Logging
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key URL patterns:**
|
||||||
|
- Unified API (OpenAI-compatible): `https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/compat/chat/completions`
|
||||||
|
- Provider-specific: `https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/{provider}/{endpoint}`
|
||||||
|
- Dynamic routes: Use route name instead of model: `dynamic/{route-name}`
|
||||||
|
|
||||||
|
## Gateway Types
|
||||||
|
|
||||||
|
1. **Unauthenticated Gateway**: Open access (not recommended for production)
|
||||||
|
2. **Authenticated Gateway**: Requires `cf-aig-authorization` header with Cloudflare API token (recommended)
|
||||||
|
|
||||||
|
## Provider Authentication Options
|
||||||
|
|
||||||
|
1. **Unified Billing**: Use AI Gateway billing to pay for inference (keyless mode - no provider API key needed)
|
||||||
|
2. **BYOK (Store Keys)**: Store provider API keys in Cloudflare dashboard
|
||||||
|
3. **Request Headers**: Include provider API key in each request
|
||||||
|
|
||||||
|
## Related Skills
|
||||||
|
|
||||||
|
- [Workers AI](../workers-ai/README.md) - For `env.AI.run()` details
|
||||||
|
- [Agents SDK](../agents-sdk/README.md) - For stateful AI patterns
|
||||||
|
- [Vectorize](../vectorize/README.md) - For RAG patterns with embeddings
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Official Docs](https://developers.cloudflare.com/ai-gateway/)
|
||||||
|
- [API Reference](https://developers.cloudflare.com/api/resources/ai_gateway/)
|
||||||
|
- [Provider Guides](https://developers.cloudflare.com/ai-gateway/usage/providers/)
|
||||||
|
- [Discord Community](https://discord.cloudflare.com)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Configuration & Setup
|
||||||
|
|
||||||
|
## Creating a Gateway
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
AI > AI Gateway > Create Gateway > Configure (auth, caching, rate limiting, logging)
|
||||||
|
|
||||||
|
### API
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.cloudflare.com/client/v4/accounts/{account_id}/ai-gateway/gateways \
|
||||||
|
-H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"id":"my-gateway","cache_ttl":3600,"rate_limiting_interval":60,"rate_limiting_limit":100,"collect_logs":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Naming:** lowercase alphanumeric + hyphens (e.g., `prod-api`, `dev-chat`)
|
||||||
|
|
||||||
|
## Wrangler Integration
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[ai]
|
||||||
|
binding = "AI"
|
||||||
|
|
||||||
|
[[ai.gateway]]
|
||||||
|
id = "my-gateway"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler secret put CF_API_TOKEN
|
||||||
|
wrangler secret put OPENAI_API_KEY # If not using BYOK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### Gateway Auth (protects gateway access)
|
||||||
|
```typescript
|
||||||
|
const client = new OpenAI({
|
||||||
|
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/openai`,
|
||||||
|
defaultHeaders: { 'cf-aig-authorization': `Bearer ${cfToken}` }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider Auth Options
|
||||||
|
|
||||||
|
**1. Unified Billing (keyless)** - pay through Cloudflare, no provider key:
|
||||||
|
```typescript
|
||||||
|
const client = new OpenAI({
|
||||||
|
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/openai`,
|
||||||
|
defaultHeaders: { 'cf-aig-authorization': `Bearer ${cfToken}` }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Supports: OpenAI, Anthropic, Google AI Studio
|
||||||
|
|
||||||
|
**2. BYOK** - store keys in dashboard (Provider Keys > Add), no key in code
|
||||||
|
|
||||||
|
**3. Request Headers** - pass provider key per request:
|
||||||
|
```typescript
|
||||||
|
const client = new OpenAI({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/openai`,
|
||||||
|
defaultHeaders: { 'cf-aig-authorization': `Bearer ${cfToken}` }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Token Permissions
|
||||||
|
|
||||||
|
- **Gateway management:** AI Gateway - Read + Edit
|
||||||
|
- **Gateway access:** AI Gateway - Read (minimum)
|
||||||
|
|
||||||
|
## Gateway Management API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List
|
||||||
|
curl https://api.cloudflare.com/client/v4/accounts/{account_id}/ai-gateway/gateways \
|
||||||
|
-H "Authorization: Bearer $CF_API_TOKEN"
|
||||||
|
|
||||||
|
# Get
|
||||||
|
curl .../gateways/{gateway_id}
|
||||||
|
|
||||||
|
# Update
|
||||||
|
curl -X PUT .../gateways/{gateway_id} \
|
||||||
|
-d '{"cache_ttl":7200,"rate_limiting_limit":200}'
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
curl -X DELETE .../gateways/{gateway_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting IDs
|
||||||
|
|
||||||
|
- **Account ID:** Dashboard > Overview > Copy
|
||||||
|
- **Gateway ID:** AI Gateway > Gateway name column
|
||||||
|
|
||||||
|
## Python Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from openai import OpenAI
|
||||||
|
import os
|
||||||
|
|
||||||
|
client = OpenAI(
|
||||||
|
api_key=os.environ.get("OPENAI_API_KEY"),
|
||||||
|
base_url=f"https://gateway.ai.cloudflare.com/v1/{os.environ['CF_ACCOUNT_ID']}/{os.environ['GATEWAY_ID']}/openai",
|
||||||
|
default_headers={"cf-aig-authorization": f"Bearer {os.environ['CF_API_TOKEN']}"}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always authenticate gateways in production**
|
||||||
|
2. **Use BYOK or unified billing** - secrets out of code
|
||||||
|
3. **Environment-specific gateways** - separate dev/staging/prod
|
||||||
|
4. **Set rate limits** - prevent runaway costs
|
||||||
|
5. **Enable logging** - track usage, debug issues
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Dynamic Routing
|
||||||
|
|
||||||
|
Configure complex routing in dashboard without code changes. Use route names instead of model names.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: 'dynamic/smart-chat', // Route name from dashboard
|
||||||
|
messages: [{ role: 'user', content: 'Hello!' }]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Node Types
|
||||||
|
|
||||||
|
| Node | Purpose | Use Case |
|
||||||
|
|------|---------|----------|
|
||||||
|
| **Conditional** | Branch on metadata | Paid vs free users, geo routing |
|
||||||
|
| **Percentage** | A/B split traffic | Model testing, gradual rollouts |
|
||||||
|
| **Rate Limit** | Enforce quotas | Per-user/team limits |
|
||||||
|
| **Budget Limit** | Cost quotas | Per-user spending caps |
|
||||||
|
| **Model** | Call provider | Final destination |
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
Pass via header (max 5 entries, flat only):
|
||||||
|
```typescript
|
||||||
|
headers: {
|
||||||
|
'cf-aig-metadata': JSON.stringify({
|
||||||
|
userId: 'user-123',
|
||||||
|
tier: 'pro',
|
||||||
|
region: 'us-east'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
**Multi-model fallback:**
|
||||||
|
```
|
||||||
|
Start → GPT-4 → On error: Claude → On error: Llama
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tiered access:**
|
||||||
|
```
|
||||||
|
Conditional: tier == 'enterprise' → GPT-4 (no limit)
|
||||||
|
Conditional: tier == 'pro' → Rate Limit 1000/hr → GPT-4o
|
||||||
|
Conditional: tier == 'free' → Rate Limit 10/hr → GPT-4o-mini
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gradual rollout:**
|
||||||
|
```
|
||||||
|
Percentage: 10% → New model, 90% → Old model
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cost-based fallback:**
|
||||||
|
```
|
||||||
|
Budget Limit: $100/day per teamId
|
||||||
|
< 80%: GPT-4
|
||||||
|
>= 80%: GPT-4o-mini
|
||||||
|
>= 100%: Error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Management
|
||||||
|
|
||||||
|
- Save changes as new version
|
||||||
|
- Test with `model: 'dynamic/route@v2'`
|
||||||
|
- Roll back by deploying previous version
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Dashboard → Gateway → Dynamic Routes:
|
||||||
|
- Request count per path
|
||||||
|
- Success/error rates
|
||||||
|
- Latency/cost by path
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Max 5 metadata entries
|
||||||
|
- Values: string/number/boolean/null only
|
||||||
|
- No nested objects
|
||||||
|
- Route names: alphanumeric + hyphens
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Features & Capabilities
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
Dashboard: Settings → Cache Responses → Enable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Custom TTL (1 hour)
|
||||||
|
headers: { 'cf-aig-cache-ttl': '3600' }
|
||||||
|
|
||||||
|
// Skip cache
|
||||||
|
headers: { 'cf-aig-skip-cache': 'true' }
|
||||||
|
|
||||||
|
// Custom cache key
|
||||||
|
headers: { 'cf-aig-cache-key': 'greeting-en' }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Limits:** TTL 60s - 30 days. **Does NOT work with streaming.**
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Dashboard: Settings → Rate-limiting → Enable
|
||||||
|
|
||||||
|
- **Fixed window:** Resets at intervals
|
||||||
|
- **Sliding window:** Rolling window (more accurate)
|
||||||
|
- Returns `429` when exceeded
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
Dashboard: Settings → Guardrails → Enable
|
||||||
|
|
||||||
|
Filter prompts/responses for inappropriate content. Actions: Flag (log) or Block (reject).
|
||||||
|
|
||||||
|
## Data Loss Prevention (DLP)
|
||||||
|
|
||||||
|
Dashboard: Settings → DLP → Enable
|
||||||
|
|
||||||
|
Detect PII (emails, SSNs, credit cards). Actions: Flag, Block, or Redact.
|
||||||
|
|
||||||
|
## Billing Modes
|
||||||
|
|
||||||
|
| Mode | Description | Setup |
|
||||||
|
|------|-------------|-------|
|
||||||
|
| **Unified Billing** | Pay through Cloudflare, no provider keys | Use `cf-aig-authorization` header only |
|
||||||
|
| **BYOK** | Store provider keys in dashboard | Add keys in Provider Keys section |
|
||||||
|
| **Pass-through** | Send provider key with each request | Include provider's auth header |
|
||||||
|
|
||||||
|
## Zero Data Retention
|
||||||
|
|
||||||
|
Dashboard: Settings → Privacy → Zero Data Retention
|
||||||
|
|
||||||
|
No prompts/responses stored. Request counts and costs still tracked.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Dashboard: Settings → Logs → Enable (up to 10M logs)
|
||||||
|
|
||||||
|
Each entry: prompt, response, provider, model, tokens, cost, duration, cache status, metadata.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Skip logging for request
|
||||||
|
headers: { 'cf-aig-collect-log': 'false' }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Export:** Use Logpush to S3, GCS, Datadog, Splunk, etc.
|
||||||
|
|
||||||
|
## Custom Cost Tracking
|
||||||
|
|
||||||
|
For models not in Cloudflare's pricing database:
|
||||||
|
|
||||||
|
Dashboard: Gateway → Settings → Custom Costs
|
||||||
|
|
||||||
|
Or via API: set `model`, `input_cost`, `output_cost`.
|
||||||
|
|
||||||
|
## Supported Providers (22+)
|
||||||
|
|
||||||
|
| Provider | Unified API | Notes |
|
||||||
|
|----------|-------------|-------|
|
||||||
|
| OpenAI | `openai/gpt-4o` | Full support |
|
||||||
|
| Anthropic | `anthropic/claude-sonnet-4-5` | Full support |
|
||||||
|
| Google AI | `google-ai-studio/gemini-2.0-flash` | Full support |
|
||||||
|
| Workers AI | `workersai/@cf/meta/llama-3` | Native |
|
||||||
|
| Azure OpenAI | `azure-openai/*` | Deployment names |
|
||||||
|
| AWS Bedrock | Provider endpoint only | `/bedrock/*` |
|
||||||
|
| Groq | `groq/*` | Fast inference |
|
||||||
|
| Mistral, Cohere, Perplexity, xAI, DeepSeek, Cerebras | Full support | - |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. Enable caching for deterministic prompts
|
||||||
|
2. Set rate limits to prevent abuse
|
||||||
|
3. Use guardrails for user-facing AI
|
||||||
|
4. Enable DLP for sensitive data
|
||||||
|
5. Use unified billing or BYOK for simpler key management
|
||||||
|
6. Enable logging for debugging
|
||||||
|
7. Use zero data retention when privacy required
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# AI Gateway SDK Integration
|
||||||
|
|
||||||
|
## Vercel AI SDK (Recommended)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createAiGateway } from 'ai-gateway-provider';
|
||||||
|
import { createOpenAI } from '@ai-sdk/openai';
|
||||||
|
import { generateText } from 'ai';
|
||||||
|
|
||||||
|
const gateway = createAiGateway({
|
||||||
|
accountId: process.env.CF_ACCOUNT_ID,
|
||||||
|
gateway: process.env.CF_GATEWAY_ID,
|
||||||
|
apiKey: process.env.CF_API_TOKEN // Optional for auth gateways
|
||||||
|
});
|
||||||
|
|
||||||
|
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||||
|
|
||||||
|
// Single model
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: gateway(openai('gpt-4o')),
|
||||||
|
prompt: 'Hello'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatic fallback array
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: gateway([
|
||||||
|
openai('gpt-4o'),
|
||||||
|
anthropic('claude-sonnet-4-5'),
|
||||||
|
openai('gpt-4o-mini')
|
||||||
|
]),
|
||||||
|
prompt: 'Complex task'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
model: gateway(openai('gpt-4o'), {
|
||||||
|
cacheKey: 'my-key',
|
||||||
|
cacheTtl: 3600,
|
||||||
|
metadata: { userId: 'u123', team: 'eng' }, // Max 5 entries
|
||||||
|
retries: { maxAttempts: 3, backoff: 'exponential' }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenAI SDK
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const client = new OpenAI({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/openai`,
|
||||||
|
defaultHeaders: { 'cf-aig-authorization': `Bearer ${cfToken}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unified API - switch providers via model name
|
||||||
|
model: 'openai/gpt-4o' // or 'anthropic/claude-sonnet-4-5'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anthropic SDK
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const client = new Anthropic({
|
||||||
|
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||||
|
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/anthropic`,
|
||||||
|
defaultHeaders: { 'cf-aig-authorization': `Bearer ${cfToken}` }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workers AI Binding
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# wrangler.toml
|
||||||
|
[ai]
|
||||||
|
binding = "AI"
|
||||||
|
[[ai.gateway]]
|
||||||
|
id = "my-gateway"
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await env.AI.run('@cf/meta/llama-3-8b-instruct',
|
||||||
|
{ messages: [...] },
|
||||||
|
{ gateway: { id: 'my-gateway', metadata: { userId: '123' } } }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## LangChain / LlamaIndex
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Use OpenAI SDK pattern with custom baseURL
|
||||||
|
new ChatOpenAI({
|
||||||
|
configuration: {
|
||||||
|
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/openai`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP / cURL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openai/chat/completions \
|
||||||
|
-H "Authorization: Bearer $OPENAI_KEY" \
|
||||||
|
-H "cf-aig-authorization: Bearer $CF_TOKEN" \
|
||||||
|
-H "cf-aig-metadata: {\"userId\":\"123\"}" \
|
||||||
|
-d '{"model":"gpt-4o","messages":[...]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Headers Reference
|
||||||
|
|
||||||
|
| Header | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `cf-aig-authorization` | Gateway auth token |
|
||||||
|
| `cf-aig-metadata` | JSON object (max 5 keys) |
|
||||||
|
| `cf-aig-cache-ttl` | Cache TTL in seconds |
|
||||||
|
| `cf-aig-skip-cache` | `true` to bypass cache |
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# AI Gateway Troubleshooting
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
| Error | Cause | Fix |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| 401 | Missing `cf-aig-authorization` header | Add header with CF API token |
|
||||||
|
| 403 | Invalid provider key / BYOK expired | Check provider key in dashboard |
|
||||||
|
| 429 | Rate limit exceeded | Increase limit or implement backoff |
|
||||||
|
|
||||||
|
### 401 Fix
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const client = new OpenAI({
|
||||||
|
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/openai`,
|
||||||
|
defaultHeaders: { 'cf-aig-authorization': `Bearer ${CF_API_TOKEN}` }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 429 Retry Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function requestWithRetry(fn, maxRetries = 3) {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try { return await fn(); }
|
||||||
|
catch (e) {
|
||||||
|
if (e.status === 429 && i < maxRetries - 1) {
|
||||||
|
await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
| Issue | Reality |
|
||||||
|
|-------|---------|
|
||||||
|
| Metadata limits | Max 5 entries, flat only (no nesting) |
|
||||||
|
| Cache key collision | Use unique keys per expected response |
|
||||||
|
| BYOK + Unified Billing | Mutually exclusive |
|
||||||
|
| Rate limit scope | Per-gateway, not per-user (use dynamic routing for per-user) |
|
||||||
|
| Log delay | 30-60 seconds normal |
|
||||||
|
| Streaming + caching | **Incompatible** |
|
||||||
|
| Model name (unified API) | Prefix required: `openai/gpt-4o`, not `gpt-4o` |
|
||||||
|
|
||||||
|
## Cache Not Working
|
||||||
|
|
||||||
|
**Causes:**
|
||||||
|
- Different request params (temperature, etc.)
|
||||||
|
- Streaming enabled
|
||||||
|
- Caching disabled in settings
|
||||||
|
|
||||||
|
**Check:** `response.headers.get('cf-aig-cache-status')` → HIT or MISS
|
||||||
|
|
||||||
|
## Logs Not Appearing
|
||||||
|
|
||||||
|
1. Check logging enabled: Dashboard → Gateway → Settings
|
||||||
|
2. Remove `cf-aig-collect-log: false` header
|
||||||
|
3. Wait 30-60 seconds
|
||||||
|
4. Check log limit (10M default)
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test connectivity
|
||||||
|
curl -v https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openai/models \
|
||||||
|
-H "Authorization: Bearer $OPENAI_KEY" \
|
||||||
|
-H "cf-aig-authorization: Bearer $CF_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Check response headers
|
||||||
|
console.log('Cache:', response.headers.get('cf-aig-cache-status'));
|
||||||
|
console.log('Request ID:', response.headers.get('cf-ray'));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|
Dashboard → AI Gateway → Select gateway
|
||||||
|
|
||||||
|
**Metrics:** Requests, tokens, latency (p50/p95/p99), cache hit rate, costs
|
||||||
|
|
||||||
|
**Log filters:** `status: error`, `provider: openai`, `cost > 0.01`, `duration > 1000`
|
||||||
|
|
||||||
|
**Export:** Logpush to S3/GCS/Datadog/Splunk
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Cloudflare AI Search Reference
|
||||||
|
|
||||||
|
Expert guidance for implementing Cloudflare AI Search (formerly AutoRAG), Cloudflare's managed semantic search and RAG service.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**AI Search** is a managed RAG (Retrieval-Augmented Generation) pipeline that combines:
|
||||||
|
- Automatic semantic indexing of your content
|
||||||
|
- Vector similarity search
|
||||||
|
- Built-in LLM generation
|
||||||
|
|
||||||
|
**Key value propositions:**
|
||||||
|
- **Zero vector management** - No manual embedding, indexing, or storage
|
||||||
|
- **Auto-indexing** - Content automatically re-indexed every 6 hours
|
||||||
|
- **Built-in generation** - Optional AI response generation from retrieved context
|
||||||
|
- **Multi-source** - Index from R2 buckets or website crawls
|
||||||
|
|
||||||
|
**Data source options:**
|
||||||
|
- **R2 bucket** - Index files from Cloudflare R2 (supports MD, TXT, HTML, PDF, DOC, CSV, JSON)
|
||||||
|
- **Website** - Crawl and index website content (requires Cloudflare-hosted domain)
|
||||||
|
|
||||||
|
**Indexing lifecycle:**
|
||||||
|
- Automatic 6-hour refresh cycle
|
||||||
|
- Manual "Force Sync" available (30s rate limit)
|
||||||
|
- Not designed for real-time updates
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
**1. Create AI Search instance in dashboard:**
|
||||||
|
- Go to Cloudflare Dashboard → AI Search → Create
|
||||||
|
- Choose data source (R2 or website)
|
||||||
|
- Configure instance name and settings
|
||||||
|
|
||||||
|
**2. Configure Worker:**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// wrangler.jsonc
|
||||||
|
{
|
||||||
|
"ai": {
|
||||||
|
"binding": "AI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Use in Worker:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const answer = await env.AI.autorag("my-search-instance").aiSearch({
|
||||||
|
query: "How do I configure caching?",
|
||||||
|
model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast"
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ answer: answer.response });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use AI Search
|
||||||
|
|
||||||
|
### AI Search vs Vectorize
|
||||||
|
|
||||||
|
| Factor | AI Search | Vectorize |
|
||||||
|
|--------|-----------|-----------|
|
||||||
|
| **Management** | Fully managed | Manual embedding + indexing |
|
||||||
|
| **Use when** | Want zero-ops RAG pipeline | Need custom embeddings/control |
|
||||||
|
| **Indexing** | Automatic (6hr cycle) | Manual via API |
|
||||||
|
| **Generation** | Built-in optional | Bring your own LLM |
|
||||||
|
| **Data sources** | R2 or website | Manual insert |
|
||||||
|
| **Best for** | Docs, support, enterprise search | Custom ML pipelines, real-time |
|
||||||
|
|
||||||
|
### AI Search vs Direct Workers AI
|
||||||
|
|
||||||
|
| Factor | AI Search | Workers AI (direct) |
|
||||||
|
|--------|-----------|---------------------|
|
||||||
|
| **Context** | Automatic retrieval | Manual context building |
|
||||||
|
| **Use when** | Need RAG (search + generate) | Simple generation tasks |
|
||||||
|
| **Indexing** | Built-in | Not applicable |
|
||||||
|
| **Best for** | Knowledge bases, docs | Simple chat, transformations |
|
||||||
|
|
||||||
|
### search() vs aiSearch()
|
||||||
|
|
||||||
|
| Method | Returns | Use When |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| `search()` | Search results only | Building custom UI, need raw chunks |
|
||||||
|
| `aiSearch()` | AI response + results | Need ready-to-use answer (chatbot, Q&A) |
|
||||||
|
|
||||||
|
### Real-time Updates Consideration
|
||||||
|
|
||||||
|
**AI Search is NOT ideal if:**
|
||||||
|
- Need real-time content updates (<6 hours)
|
||||||
|
- Content changes multiple times per hour
|
||||||
|
- Strict freshness requirements
|
||||||
|
|
||||||
|
**AI Search IS ideal if:**
|
||||||
|
- Content relatively stable (docs, policies, knowledge bases)
|
||||||
|
- 6-hour refresh acceptable
|
||||||
|
- Prefer zero-ops over real-time
|
||||||
|
|
||||||
|
## Platform Limits
|
||||||
|
|
||||||
|
| Limit | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Max instances per account | 10 |
|
||||||
|
| Max files per instance | 100,000 |
|
||||||
|
| Max file size | 4 MB |
|
||||||
|
| Index frequency | Every 6 hours |
|
||||||
|
| Force Sync rate limit | Once per 30 seconds |
|
||||||
|
| Filter nesting depth | 2 levels |
|
||||||
|
| Filters per compound | 10 |
|
||||||
|
| Score threshold range | 0.0 - 1.0 |
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
Navigate these references based on your task:
|
||||||
|
|
||||||
|
| Task | Read | Est. Time |
|
||||||
|
|------|------|-----------|
|
||||||
|
| **Understand AI Search** | README only | 5 min |
|
||||||
|
| **Implement basic search** | README → api.md | 10 min |
|
||||||
|
| **Configure data source** | README → configuration.md | 10 min |
|
||||||
|
| **Production patterns** | patterns.md | 15 min |
|
||||||
|
| **Debug issues** | gotchas.md | 10 min |
|
||||||
|
| **Full implementation** | README → api.md → patterns.md | 30 min |
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
- **[api.md](api.md)** - API endpoints, methods, TypeScript interfaces
|
||||||
|
- **[configuration.md](configuration.md)** - Setup, data sources, wrangler config
|
||||||
|
- **[patterns.md](patterns.md)** - Common patterns, decision guidance, code examples
|
||||||
|
- **[gotchas.md](gotchas.md)** - Troubleshooting, code-level gotchas, limits
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Cloudflare AI Search Docs](https://developers.cloudflare.com/ai-search/)
|
||||||
|
- [Workers AI Docs](https://developers.cloudflare.com/workers-ai/)
|
||||||
|
- [Vectorize Docs](https://developers.cloudflare.com/vectorize/)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# AI Search API Reference
|
||||||
|
|
||||||
|
## Workers Binding
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const answer = await env.AI.autorag("instance-name").aiSearch(options);
|
||||||
|
const results = await env.AI.autorag("instance-name").search(options);
|
||||||
|
const instances = await env.AI.autorag("_").listInstances();
|
||||||
|
```
|
||||||
|
|
||||||
|
## aiSearch() Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AiSearchOptions {
|
||||||
|
query: string; // User query
|
||||||
|
model: string; // Workers AI model ID
|
||||||
|
system_prompt?: string; // LLM instructions
|
||||||
|
rewrite_query?: boolean; // Fix typos (default: false)
|
||||||
|
max_num_results?: number; // Max chunks (default: 10)
|
||||||
|
ranking_options?: { score_threshold?: number }; // 0.0-1.0 (default: 0.3)
|
||||||
|
reranking?: { enabled: boolean; model: string };
|
||||||
|
stream?: boolean; // Stream response (default: false)
|
||||||
|
filters?: Filter; // Metadata filters
|
||||||
|
page?: string; // Pagination token
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AiSearchResponse {
|
||||||
|
search_query: string; // Query used (rewritten if enabled)
|
||||||
|
response: string; // AI-generated answer
|
||||||
|
data: SearchResult[]; // Retrieved chunks
|
||||||
|
has_more: boolean;
|
||||||
|
next_page?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
content: string;
|
||||||
|
metadata: { filename: string; folder: string; timestamp: number };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Filters
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Comparison
|
||||||
|
{ column: "folder", operator: "gte", value: "docs/" }
|
||||||
|
|
||||||
|
// Compound
|
||||||
|
{ operator: "and", filters: [
|
||||||
|
{ column: "folder", operator: "gte", value: "docs/" },
|
||||||
|
{ column: "timestamp", operator: "gte", value: 1704067200 }
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Operators:** `eq`, `ne`, `gt`, `gte`, `lt`, `lte`
|
||||||
|
|
||||||
|
**Built-in metadata:** `filename`, `folder`, `timestamp` (Unix seconds)
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const stream = await env.AI.autorag("docs").aiSearch({ query, model, stream: true });
|
||||||
|
return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Types
|
||||||
|
|
||||||
|
| Error | Cause |
|
||||||
|
|-------|-------|
|
||||||
|
| `AutoRAGNotFoundError` | Instance doesn't exist |
|
||||||
|
| `AutoRAGUnauthorizedError` | Invalid/missing token |
|
||||||
|
| `AutoRAGValidationError` | Invalid parameters |
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/autorag/rags/{NAME}/ai-search \
|
||||||
|
-H "Authorization: Bearer {TOKEN}" \
|
||||||
|
-d '{"query": "...", "model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Service API token with "AI Search - Read" permission.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# AI Search Configuration
|
||||||
|
|
||||||
|
## Worker Setup
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// wrangler.jsonc
|
||||||
|
{
|
||||||
|
"ai": { "binding": "AI" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Env {
|
||||||
|
AI: Ai;
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await env.AI.autorag("my-instance").aiSearch({
|
||||||
|
query: "How do I configure caching?",
|
||||||
|
model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
|
||||||
|
### R2 Bucket
|
||||||
|
|
||||||
|
Dashboard: AI Search → Create Instance → Select R2 bucket
|
||||||
|
|
||||||
|
**Supported formats:** `.md`, `.txt`, `.html`, `.pdf`, `.doc`, `.docx`, `.csv`, `.json`
|
||||||
|
|
||||||
|
**Auto-indexed metadata:** `filename`, `folder`, `timestamp`
|
||||||
|
|
||||||
|
### Website Crawler
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Domain on Cloudflare
|
||||||
|
- `sitemap.xml` at root
|
||||||
|
- Bot protection must allow `CloudflareAISearch` user agent
|
||||||
|
|
||||||
|
## Path Filtering (R2)
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/**/*.md # All .md in docs/ recursively
|
||||||
|
**/*.draft.md # Exclude (use in exclude patterns)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Indexing
|
||||||
|
|
||||||
|
- **Automatic:** Every 6 hours
|
||||||
|
- **Force Sync:** Dashboard button (30s rate limit between syncs)
|
||||||
|
- **Pause:** Settings → Pause Indexing (existing index remains searchable)
|
||||||
|
|
||||||
|
## Service API Token
|
||||||
|
|
||||||
|
Dashboard: AI Search → Instance → Use AI Search → API → Create Token
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
- **Read** - search operations
|
||||||
|
- **Edit** - instance management
|
||||||
|
|
||||||
|
Store securely:
|
||||||
|
```bash
|
||||||
|
wrangler secret put AI_SEARCH_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Environment
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# wrangler.toml
|
||||||
|
[env.production.vars]
|
||||||
|
AI_SEARCH_INSTANCE = "prod-docs"
|
||||||
|
|
||||||
|
[env.staging.vars]
|
||||||
|
AI_SEARCH_INSTANCE = "staging-docs"
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const answer = await env.AI.autorag(env.AI_SEARCH_INSTANCE).aiSearch({ query });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const instances = await env.AI.autorag("_").listInstances();
|
||||||
|
console.log(instances.find(i => i.name === "docs"));
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard shows: files indexed, status, last index time, storage usage.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# AI Search Gotchas
|
||||||
|
|
||||||
|
## Type Safety
|
||||||
|
|
||||||
|
**Timestamp precision:** Use seconds (10-digit), not milliseconds.
|
||||||
|
```typescript
|
||||||
|
const nowInSeconds = Math.floor(Date.now() / 1000); // Correct
|
||||||
|
```
|
||||||
|
|
||||||
|
**Folder prefix matching:** Use `gte` for "starts with" on paths.
|
||||||
|
```typescript
|
||||||
|
filters: { column: "folder", operator: "gte", value: "docs/api/" } // Matches nested
|
||||||
|
```
|
||||||
|
|
||||||
|
## Filter Limitations
|
||||||
|
|
||||||
|
| Limit | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Max nesting depth | 2 levels |
|
||||||
|
| Filters per compound | 10 |
|
||||||
|
| `or` operator | Same column, `eq` only |
|
||||||
|
|
||||||
|
**OR restriction example:**
|
||||||
|
```typescript
|
||||||
|
// ✅ Valid: same column, eq only
|
||||||
|
{ operator: "or", filters: [
|
||||||
|
{ column: "folder", operator: "eq", value: "docs/" },
|
||||||
|
{ column: "folder", operator: "eq", value: "guides/" }
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Indexing Issues
|
||||||
|
|
||||||
|
| Problem | Cause | Solution |
|
||||||
|
|---------|-------|----------|
|
||||||
|
| File not indexed | Unsupported format or >4MB | Check format (.md/.txt/.html/.pdf/.doc/.csv/.json) |
|
||||||
|
| Index out of sync | 6-hour index cycle | Wait or use "Force Sync" (30s rate limit) |
|
||||||
|
| Empty results | Index incomplete | Check dashboard for indexing status |
|
||||||
|
|
||||||
|
## Auth Errors
|
||||||
|
|
||||||
|
| Error | Cause | Fix |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| `AutoRAGUnauthorizedError` | Invalid/missing token | Create Service API token with AI Search permissions |
|
||||||
|
| `AutoRAGNotFoundError` | Wrong instance name | Verify exact name from dashboard |
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
**Slow responses (>3s):**
|
||||||
|
```typescript
|
||||||
|
// Add score threshold + limit results
|
||||||
|
ranking_options: { score_threshold: 0.5 },
|
||||||
|
max_num_results: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
**Empty results debug:**
|
||||||
|
1. Remove filters, test basic query
|
||||||
|
2. Lower `score_threshold` to 0.1
|
||||||
|
3. Check index is populated
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
| Resource | Limit |
|
||||||
|
|----------|-------|
|
||||||
|
| Instances per account | 10 |
|
||||||
|
| Files per instance | 100,000 |
|
||||||
|
| Max file size | 4 MB |
|
||||||
|
| Index frequency | 6 hours |
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
**Use env vars for instance names:**
|
||||||
|
```typescript
|
||||||
|
const answer = await env.AI.autorag(env.AI_SEARCH_INSTANCE).aiSearch({...});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Handle specific error types:**
|
||||||
|
```typescript
|
||||||
|
if (error instanceof AutoRAGNotFoundError) { /* 404 */ }
|
||||||
|
if (error instanceof AutoRAGUnauthorizedError) { /* 401 */ }
|
||||||
|
```
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# AI Search Patterns
|
||||||
|
|
||||||
|
## search() vs aiSearch()
|
||||||
|
|
||||||
|
| Use | Method | Returns |
|
||||||
|
|-----|--------|---------|
|
||||||
|
| Custom UI, analytics | `search()` | Raw chunks only (~100-300ms) |
|
||||||
|
| Chatbots, Q&A | `aiSearch()` | AI response + chunks (~500-2000ms) |
|
||||||
|
|
||||||
|
## rewrite_query
|
||||||
|
|
||||||
|
| Setting | Use When |
|
||||||
|
|---------|----------|
|
||||||
|
| `true` | User input (typos, vague queries) |
|
||||||
|
| `false` | LLM-generated queries (already optimized) |
|
||||||
|
|
||||||
|
## Multitenancy (Folder-Based)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const answer = await env.AI.autorag("saas-docs").aiSearch({
|
||||||
|
query: "refund policy",
|
||||||
|
model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
|
||||||
|
filters: {
|
||||||
|
column: "folder",
|
||||||
|
operator: "gte", // "starts with" pattern
|
||||||
|
value: `tenants/${tenantId}/`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const stream = await env.AI.autorag("docs").aiSearch({
|
||||||
|
query, model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", stream: true
|
||||||
|
});
|
||||||
|
return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Score Threshold
|
||||||
|
|
||||||
|
| Threshold | Use |
|
||||||
|
|-----------|-----|
|
||||||
|
| 0.3 (default) | Broad recall, exploratory |
|
||||||
|
| 0.5 | Balanced, production default |
|
||||||
|
| 0.7 | High precision, critical accuracy |
|
||||||
|
|
||||||
|
## System Prompt Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const systemPrompt = `You are a documentation assistant.
|
||||||
|
- Answer ONLY based on provided context
|
||||||
|
- If context doesn't contain answer, say "I don't have information"
|
||||||
|
- Include code examples from context`;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compound Filters
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// OR: Multiple folders
|
||||||
|
filters: {
|
||||||
|
operator: "or",
|
||||||
|
filters: [
|
||||||
|
{ column: "folder", operator: "gte", value: "docs/api/" },
|
||||||
|
{ column: "folder", operator: "gte", value: "docs/auth/" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// AND: Folder + date
|
||||||
|
filters: {
|
||||||
|
operator: "and",
|
||||||
|
filters: [
|
||||||
|
{ column: "folder", operator: "gte", value: "docs/" },
|
||||||
|
{ column: "timestamp", operator: "gte", value: oneWeekAgoSeconds }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reranking
|
||||||
|
|
||||||
|
Enable for high-stakes use cases (adds ~300ms latency):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
reranking: { enabled: true, model: "@cf/baai/bge-reranker-base" }
|
||||||
|
```
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Cloudflare Workers Analytics Engine Reference
|
||||||
|
|
||||||
|
Expert guidance for implementing unlimited-cardinality analytics at scale using Cloudflare Workers Analytics Engine.
|
||||||
|
|
||||||
|
## What is Analytics Engine?
|
||||||
|
|
||||||
|
Time-series analytics database designed for high-cardinality data (millions of unique dimensions). Write data points from Workers, query via SQL API. Use for:
|
||||||
|
- Custom user-facing analytics dashboards
|
||||||
|
- Usage-based billing & metering
|
||||||
|
- Per-customer/per-feature monitoring
|
||||||
|
- High-frequency instrumentation without performance impact
|
||||||
|
|
||||||
|
**Key Capability:** Track metrics with unlimited unique values (e.g., millions of user IDs, API keys) without performance degradation.
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
| Concept | Description | Example |
|
||||||
|
|---------|-------------|---------|
|
||||||
|
| **Dataset** | Logical table for related metrics | `api_requests`, `user_events` |
|
||||||
|
| **Data Point** | Single measurement with timestamp | One API request's metrics |
|
||||||
|
| **Blobs** | String dimensions (max 20) | endpoint, method, status, user_id |
|
||||||
|
| **Doubles** | Numeric values (max 20) | latency_ms, request_count, bytes |
|
||||||
|
| **Indexes** | Filtered blobs for efficient queries | customer_id, api_key |
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
| Task | Start Here | Then Read |
|
||||||
|
|------|------------|-----------|
|
||||||
|
| **First-time setup** | [configuration.md](configuration.md) → [api.md](api.md) → [patterns.md](patterns.md) | |
|
||||||
|
| **Writing data** | [api.md](api.md) → [gotchas.md](gotchas.md) (sampling) | |
|
||||||
|
| **Querying data** | [api.md](api.md) (SQL API) → [patterns.md](patterns.md) (examples) | |
|
||||||
|
| **Debugging** | [gotchas.md](gotchas.md) → [api.md](api.md) (limits) | |
|
||||||
|
| **Optimization** | [patterns.md](patterns.md) (anti-patterns) → [gotchas.md](gotchas.md) | |
|
||||||
|
|
||||||
|
## When to Use Analytics Engine
|
||||||
|
|
||||||
|
```
|
||||||
|
Need to track metrics? → Yes
|
||||||
|
↓
|
||||||
|
Millions of unique dimension values? → Yes
|
||||||
|
↓
|
||||||
|
Need real-time queries? → Yes
|
||||||
|
↓
|
||||||
|
Use Analytics Engine ✓
|
||||||
|
|
||||||
|
Alternative scenarios:
|
||||||
|
- Low cardinality (<10k unique values) → Workers Analytics (free tier)
|
||||||
|
- Complex joins/relations → D1 Database
|
||||||
|
- Logs/debugging → Tail Workers (logpush)
|
||||||
|
- External tools → Send to external analytics (Datadog, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Add binding to `wrangler.jsonc`:
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"analytics_engine_datasets": [
|
||||||
|
{ "binding": "ANALYTICS", "dataset": "my_events" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Write data points (fire-and-forget, no await):
|
||||||
|
```typescript
|
||||||
|
env.ANALYTICS.writeDataPoint({
|
||||||
|
blobs: ["/api/users", "GET", "200"],
|
||||||
|
doubles: [145.2, 1], // latency_ms, count
|
||||||
|
indexes: [customerId]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Query via SQL API (HTTP):
|
||||||
|
```sql
|
||||||
|
SELECT blob1, SUM(double2) AS total_requests
|
||||||
|
FROM my_events
|
||||||
|
WHERE index1 = 'customer_123'
|
||||||
|
AND timestamp >= NOW() - INTERVAL '7' DAY
|
||||||
|
GROUP BY blob1
|
||||||
|
ORDER BY total_requests DESC
|
||||||
|
```
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
- **[configuration.md](configuration.md)** - Setup, bindings, TypeScript types, limits
|
||||||
|
- **[api.md](api.md)** - `writeDataPoint()`, SQL API, query syntax
|
||||||
|
- **[patterns.md](patterns.md)** - Use cases, examples, anti-patterns
|
||||||
|
- **[gotchas.md](gotchas.md)** - Sampling, index selection, troubleshooting
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Cloudflare Analytics Engine Docs](https://developers.cloudflare.com/analytics/analytics-engine/)
|
||||||
|
- [GraphQL Analytics API Reference](../graphql-api/) - Query built-in Cloudflare analytics (HTTP, Workers, DNS, Firewall, etc.)
|
||||||
|
- [Observability Reference](../observability/) - Workers Logs, Traces, and real-time debugging
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Analytics Engine API Reference
|
||||||
|
|
||||||
|
## Writing Data
|
||||||
|
|
||||||
|
### `writeDataPoint()`
|
||||||
|
|
||||||
|
Fire-and-forget (returns `void`, not Promise). Writes happen asynchronously.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AnalyticsEngineDataPoint {
|
||||||
|
blobs?: string[]; // Up to 20 strings (dimensions), 16KB each
|
||||||
|
doubles?: number[]; // Up to 20 numbers (metrics)
|
||||||
|
indexes?: string[]; // 1 indexed string for high-cardinality filtering
|
||||||
|
}
|
||||||
|
|
||||||
|
env.ANALYTICS.writeDataPoint({
|
||||||
|
blobs: ["/api/users", "GET", "200"],
|
||||||
|
doubles: [145.2, 1], // latency_ms, count
|
||||||
|
indexes: ["customer_abc123"]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behaviors:** No await needed, no error thrown (check tail logs), auto-sampled at high volumes, auto-timestamped.
|
||||||
|
|
||||||
|
**Blob vs Index:** Blob for GROUP BY (<100k unique), Index for filter-only (millions unique).
|
||||||
|
|
||||||
|
### Full Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
const start = Date.now();
|
||||||
|
const url = new URL(request.url);
|
||||||
|
try {
|
||||||
|
const response = await handleRequest(request);
|
||||||
|
env.ANALYTICS.writeDataPoint({
|
||||||
|
blobs: [url.pathname, request.method, response.status.toString()],
|
||||||
|
doubles: [Date.now() - start, 1],
|
||||||
|
indexes: [request.headers.get("x-api-key") || "anonymous"]
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
env.ANALYTICS.writeDataPoint({
|
||||||
|
blobs: [url.pathname, request.method, "500"],
|
||||||
|
doubles: [Date.now() - start, 1, 0],
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQL API (External Only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.cloudflare.com/client/v4/accounts/{account_id}/analytics_engine/sql \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d "SELECT blob1 AS endpoint, COUNT(*) AS requests FROM dataset WHERE timestamp >= NOW() - INTERVAL '1' HOUR GROUP BY blob1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Column References
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- blob1..blob20, double1..double20, index1, timestamp
|
||||||
|
SELECT blob1 AS endpoint, SUM(double1) AS latency, COUNT(*) AS requests
|
||||||
|
FROM my_dataset
|
||||||
|
WHERE index1 = 'customer_123' AND timestamp >= NOW() - INTERVAL '7' DAY
|
||||||
|
GROUP BY blob1
|
||||||
|
HAVING COUNT(*) > 100
|
||||||
|
ORDER BY requests DESC LIMIT 100
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aggregations:** `SUM()`, `AVG()`, `COUNT()`, `MIN()`, `MAX()`, `quantile(0.95)()`
|
||||||
|
|
||||||
|
**Time ranges:** `NOW() - INTERVAL '1' HOUR`, `BETWEEN '2026-01-01' AND '2026-01-31'`
|
||||||
|
|
||||||
|
### Query Examples
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Top endpoints
|
||||||
|
SELECT blob1, COUNT(*) AS requests, AVG(double1) AS avg_latency
|
||||||
|
FROM api_requests WHERE timestamp >= NOW() - INTERVAL '24' HOUR
|
||||||
|
GROUP BY blob1 ORDER BY requests DESC LIMIT 20
|
||||||
|
|
||||||
|
-- Error rate
|
||||||
|
SELECT blob1, COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN blob3 LIKE '5%' THEN 1 ELSE 0 END) AS errors
|
||||||
|
FROM api_requests WHERE timestamp >= NOW() - INTERVAL '1' HOUR
|
||||||
|
GROUP BY blob1 HAVING total > 50
|
||||||
|
|
||||||
|
-- P95 latency
|
||||||
|
SELECT blob1, quantile(0.95)(double1) AS p95
|
||||||
|
FROM api_requests GROUP BY blob1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"data": [{"endpoint": "/api/users", "requests": 1523}], "rows": 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
| Resource | Limit |
|
||||||
|
|----------|-------|
|
||||||
|
| Blobs/Doubles per point | 20 each |
|
||||||
|
| Indexes per point | 1 |
|
||||||
|
| Blob/Index size | 16KB |
|
||||||
|
| Data retention | 90 days |
|
||||||
|
| Query timeout | 30s |
|
||||||
|
|
||||||
|
**Critical:** High write volumes (>1M/min) trigger automatic sampling.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Analytics Engine Configuration
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Add binding to `wrangler.jsonc`
|
||||||
|
2. Deploy Worker
|
||||||
|
3. Dataset created automatically on first write
|
||||||
|
4. Query via SQL API
|
||||||
|
|
||||||
|
## wrangler.jsonc
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "my-worker",
|
||||||
|
"analytics_engine_datasets": [
|
||||||
|
{ "binding": "ANALYTICS", "dataset": "my_events" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple datasets for separate concerns:
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"analytics_engine_datasets": [
|
||||||
|
{ "binding": "API_ANALYTICS", "dataset": "api_requests" },
|
||||||
|
{ "binding": "USER_EVENTS", "dataset": "user_activity" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Env {
|
||||||
|
ANALYTICS: AnalyticsEngineDataset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
// No await - returns void, fire-and-forget
|
||||||
|
env.ANALYTICS.writeDataPoint({
|
||||||
|
blobs: [pathname, method, status], // String dimensions (max 20)
|
||||||
|
doubles: [latency, 1], // Numeric metrics (max 20)
|
||||||
|
indexes: [apiKey] // High-cardinality filter (max 1)
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Point Limits
|
||||||
|
|
||||||
|
| Field | Limit | SQL Access |
|
||||||
|
|-------|-------|------------|
|
||||||
|
| blobs | 20 strings, 16KB each | `blob1`...`blob20` |
|
||||||
|
| doubles | 20 numbers | `double1`...`double20` |
|
||||||
|
| indexes | 1 string, 16KB | `index1` |
|
||||||
|
|
||||||
|
## Write Behavior
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| <1M writes/min | All accepted |
|
||||||
|
| >1M writes/min | Automatic sampling |
|
||||||
|
| Invalid data | Silent failure (check tail logs) |
|
||||||
|
|
||||||
|
**Mitigate sampling:** Pre-aggregate, use multiple datasets, write only critical metrics.
|
||||||
|
|
||||||
|
## Query Limits
|
||||||
|
|
||||||
|
| Resource | Limit |
|
||||||
|
|----------|-------|
|
||||||
|
| Query timeout | 30 seconds |
|
||||||
|
| Data retention | 90 days (default) |
|
||||||
|
| Result size | ~10MB |
|
||||||
|
|
||||||
|
## Cost
|
||||||
|
|
||||||
|
**Free tier:** 10M writes/month, 1M reads/month
|
||||||
|
|
||||||
|
**Paid:** $0.05 per 1M writes, $1.00 per 1M reads
|
||||||
|
|
||||||
|
## Environment-Specific
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"analytics_engine_datasets": [
|
||||||
|
{ "binding": "ANALYTICS", "dataset": "prod_events" }
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"staging": {
|
||||||
|
"analytics_engine_datasets": [
|
||||||
|
{ "binding": "ANALYTICS", "dataset": "staging_events" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx wrangler tail # Check for sampling/write errors
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check write activity
|
||||||
|
SELECT DATE_TRUNC('hour', timestamp) AS hour, COUNT(*) AS writes
|
||||||
|
FROM my_dataset
|
||||||
|
WHERE timestamp >= NOW() - INTERVAL '24' HOUR
|
||||||
|
GROUP BY hour
|
||||||
|
```
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Analytics Engine Gotchas
|
||||||
|
|
||||||
|
## Critical Issues
|
||||||
|
|
||||||
|
### Sampling at High Volumes
|
||||||
|
|
||||||
|
**Problem:** Queries return fewer points than written at >1M writes/min.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```typescript
|
||||||
|
// Pre-aggregate before writing
|
||||||
|
let buffer = { count: 0, total: 0 };
|
||||||
|
buffer.count++; buffer.total += value;
|
||||||
|
|
||||||
|
// Write once per second instead of per request
|
||||||
|
if (Date.now() % 1000 === 0) {
|
||||||
|
env.ANALYTICS.writeDataPoint({ doubles: [buffer.count, buffer.total] });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Detection:** `npx wrangler tail` → look for "sampling enabled"
|
||||||
|
|
||||||
|
### writeDataPoint Returns void
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Pointless await
|
||||||
|
await env.ANALYTICS.writeDataPoint({...});
|
||||||
|
|
||||||
|
// ✅ Fire-and-forget
|
||||||
|
env.ANALYTICS.writeDataPoint({...});
|
||||||
|
```
|
||||||
|
|
||||||
|
Writes can fail silently. Check tail logs.
|
||||||
|
|
||||||
|
### Index vs Blob
|
||||||
|
|
||||||
|
| Cardinality | Use | Example |
|
||||||
|
|-------------|-----|---------|
|
||||||
|
| Millions | **Index** | user_id, api_key |
|
||||||
|
| Hundreds | **Blob** | endpoint, status_code, country |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct
|
||||||
|
{ blobs: [method, path, status], indexes: [userId] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Can't Query from Workers
|
||||||
|
|
||||||
|
Query API requires HTTP auth. Use external service or cache in KV/D1.
|
||||||
|
|
||||||
|
### No Custom Timestamps
|
||||||
|
|
||||||
|
Auto-generated at write time. Store original in blob if needed.
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
| Error | Fix |
|
||||||
|
|-------|-----|
|
||||||
|
| Binding not found | Check wrangler.jsonc, redeploy |
|
||||||
|
| No data in query | Wait 30s; check dataset name; check time range |
|
||||||
|
| Query timeout | Add time filter; use index for filtering |
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
| Resource | Limit |
|
||||||
|
|----------|-------|
|
||||||
|
| Blobs per point | 20 |
|
||||||
|
| Doubles per point | 20 |
|
||||||
|
| Indexes per point | 1 |
|
||||||
|
| Blob/Index size | 16KB |
|
||||||
|
| Write rate (no sampling) | ~1M/min |
|
||||||
|
| Retention | 90 days |
|
||||||
|
| Query timeout | 30s |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
✅ Pre-aggregate at high volumes
|
||||||
|
✅ Use index for high-cardinality (millions)
|
||||||
|
✅ Always include time filter in queries
|
||||||
|
✅ Design schema before coding
|
||||||
|
|
||||||
|
❌ Don't await writeDataPoint
|
||||||
|
❌ Don't use index for low-cardinality
|
||||||
|
❌ Don't query without time range
|
||||||
|
❌ Don't assume all writes succeed
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Analytics Engine Patterns
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
| Use Case | Key Metrics | Index On |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| API Metering | requests, bytes, compute_units | api_key |
|
||||||
|
| Feature Usage | feature, action, duration | user_id |
|
||||||
|
| Error Tracking | error_type, endpoint, count | customer_id |
|
||||||
|
| Performance | latency_ms, cache_status | endpoint |
|
||||||
|
| A/B Testing | variant, conversions | user_id |
|
||||||
|
|
||||||
|
## API Metering (Billing)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
env.ANALYTICS.writeDataPoint({
|
||||||
|
blobs: [pathname, method, status, tier],
|
||||||
|
doubles: [1, computeUnits, bytes, latencyMs],
|
||||||
|
indexes: [apiKey]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query: Monthly usage by customer
|
||||||
|
// SELECT index1 AS api_key, SUM(double2) AS compute_units
|
||||||
|
// FROM usage WHERE timestamp >= DATE_TRUNC('month', NOW()) GROUP BY index1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Tracking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
env.ANALYTICS.writeDataPoint({
|
||||||
|
blobs: [endpoint, method, errorName, errorMessage.slice(0, 1000)],
|
||||||
|
doubles: [1, timeToErrorMs],
|
||||||
|
indexes: [customerId]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Monitoring
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
env.ANALYTICS.writeDataPoint({
|
||||||
|
blobs: [pathname, method, cacheStatus, status],
|
||||||
|
doubles: [latencyMs, 1],
|
||||||
|
indexes: [userId]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query: P95 latency by endpoint
|
||||||
|
// SELECT blob1, quantile(0.95)(double1) AS p95_ms FROM perf GROUP BY blob1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| ❌ Wrong | ✅ Correct |
|
||||||
|
|----------|-----------|
|
||||||
|
| `await writeDataPoint()` | `writeDataPoint()` (fire-and-forget) |
|
||||||
|
| `indexes: [method]` (low cardinality) | `blobs: [method]`, `indexes: [userId]` |
|
||||||
|
| `blobs: [JSON.stringify(obj)]` | Store ID in blob, full object in D1/KV |
|
||||||
|
| Write every request at 10M/min | Pre-aggregate per second |
|
||||||
|
| Query from Worker | Query from external service/API |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Design schema upfront** - Document blob/double/index assignments
|
||||||
|
2. **Always include count metric** - `doubles: [latency, 1]` for AVG calculations
|
||||||
|
3. **Use enums for blobs** - Consistent values like `Status.SUCCESS`
|
||||||
|
4. **Handle sampling** - Use ratios (avg_latency = SUM(latency)/SUM(count))
|
||||||
|
5. **Test queries early** - Validate schema before heavy writes
|
||||||
|
|
||||||
|
## Schema Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Dataset: my_metrics
|
||||||
|
*
|
||||||
|
* Blobs:
|
||||||
|
* blob1: endpoint, blob2: method, blob3: status
|
||||||
|
*
|
||||||
|
* Doubles:
|
||||||
|
* double1: latency_ms, double2: count (always 1)
|
||||||
|
*
|
||||||
|
* Indexes:
|
||||||
|
* index1: customer_id (high cardinality)
|
||||||
|
*/
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Cloudflare API Shield Reference
|
||||||
|
|
||||||
|
Expert guidance for API Shield - comprehensive API security suite for discovery, protection, and monitoring.
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
| Task | Files to Read |
|
||||||
|
|------|---------------|
|
||||||
|
| Initial setup | README → configuration.md |
|
||||||
|
| Implement JWT validation | configuration.md → api.md |
|
||||||
|
| Add schema validation | configuration.md → patterns.md |
|
||||||
|
| Detect API attacks | patterns.md → api.md |
|
||||||
|
| Debug issues | gotchas.md |
|
||||||
|
|
||||||
|
## Feature Selection
|
||||||
|
|
||||||
|
What protection do you need?
|
||||||
|
|
||||||
|
```
|
||||||
|
├─ Validate request/response structure → Schema Validation 2.0 (configuration.md)
|
||||||
|
├─ Verify auth tokens → JWT Validation (configuration.md)
|
||||||
|
├─ Client certificates → mTLS (configuration.md)
|
||||||
|
├─ Detect BOLA attacks → BOLA Detection (patterns.md)
|
||||||
|
├─ Track auth coverage → Auth Posture (patterns.md)
|
||||||
|
├─ Stop volumetric abuse → Abuse Detection (patterns.md)
|
||||||
|
└─ Discover shadow APIs → API Discovery (api.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
- **[configuration.md](configuration.md)** - Setup, session identifiers, rules, token/mTLS configs
|
||||||
|
- **[api.md](api.md)** - Endpoint management, discovery, validation APIs, GraphQL operations
|
||||||
|
- **[patterns.md](patterns.md)** - Common patterns, progressive rollout, OWASP mappings, workflows
|
||||||
|
- **[gotchas.md](gotchas.md)** - Troubleshooting, false positives, performance, best practices
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
API Shield: Enterprise-grade API security (Discovery, Schema Validation 2.0, JWT, mTLS, BOLA Detection, Auth Posture). Available as Enterprise add-on with preview access.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [API Shield Docs](https://developers.cloudflare.com/api-shield/)
|
||||||
|
- [API Reference](https://developers.cloudflare.com/api/resources/api_gateway/)
|
||||||
|
- [OWASP API Security Top 10](https://owasp.org/www-project-api-security/)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
Base: `/zones/{zone_id}/api_gateway`
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /operations # List
|
||||||
|
GET /operations/{op_id} # Get single
|
||||||
|
POST /operations/item # Create: {endpoint,host,method}
|
||||||
|
POST /operations # Bulk: {operations:[{endpoint,host,method}]}
|
||||||
|
DELETE /operations/{op_id} # Delete
|
||||||
|
DELETE /operations # Bulk delete: {operation_ids:[...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /discovery/operations # List discovered
|
||||||
|
PATCH /discovery/operations/{op_id} # Update: {state:"saved"|"ignored"}
|
||||||
|
PATCH /discovery/operations # Bulk: {operation_ids:{id:{state}}}
|
||||||
|
GET /discovery # OpenAPI export
|
||||||
|
```
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /configuration # Get session ID config
|
||||||
|
PUT /configuration # Update: {auth_id_characteristics:[{name,type:"header"|"cookie"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Token Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /token_validation # List
|
||||||
|
POST /token_validation # Create: {name,location:{header:"..."},jwks:"..."}
|
||||||
|
POST /jwt_validation_rules # Rule: {name,hostname,token_validation_id,action:"block"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workers Integration
|
||||||
|
|
||||||
|
### Access JWT Claims
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
async fetch(req, env) {
|
||||||
|
// Access validated JWT payload
|
||||||
|
const jwt = req.cf?.jwt?.payload?.[env.JWT_CONFIG_ID]?.[0];
|
||||||
|
if (jwt) {
|
||||||
|
const userId = jwt.sub;
|
||||||
|
const role = jwt.role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access mTLS Info
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
async fetch(req, env) {
|
||||||
|
const tls = req.cf?.tlsClientAuth;
|
||||||
|
if (tls?.certVerified === 'SUCCESS') {
|
||||||
|
const fingerprint = tls.certFingerprintSHA256;
|
||||||
|
// Authenticated client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic JWKS Update
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
async scheduled(event, env) {
|
||||||
|
const jwks = await (await fetch('https://auth.example.com/.well-known/jwks.json')).json();
|
||||||
|
await fetch(`https://api.cloudflare.com/client/v4/zones/${env.ZONE_ID}/api_gateway/token_validation/${env.CONFIG_ID}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Authorization': `Bearer ${env.CF_API_TOKEN}`, 'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({jwks: JSON.stringify(jwks)})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Firewall Fields
|
||||||
|
|
||||||
|
### Core Fields
|
||||||
|
```js
|
||||||
|
cf.api_gateway.auth_id_present // Session ID present
|
||||||
|
cf.api_gateway.request_violates_schema // Schema violation
|
||||||
|
cf.api_gateway.fallthrough_triggered // No endpoint match
|
||||||
|
cf.tls_client_auth.cert_verified // mTLS cert valid
|
||||||
|
cf.tls_client_auth.cert_fingerprint_sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
### JWT Validation (2026)
|
||||||
|
```js
|
||||||
|
// Modern validation syntax
|
||||||
|
is_jwt_valid(http.request.jwt.payload["{config_id}"][0])
|
||||||
|
|
||||||
|
// Legacy (still supported)
|
||||||
|
cf.api_gateway.jwt_claims_valid
|
||||||
|
|
||||||
|
// Extract claims
|
||||||
|
lookup_json_string(http.request.jwt.payload["{config_id}"][0], "claim_name")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Risk Labels (2026)
|
||||||
|
```js
|
||||||
|
// BOLA detection
|
||||||
|
cf.api_gateway.cf-risk-bola-enumeration // Sequential resource access detected
|
||||||
|
cf.api_gateway.cf-risk-bola-pollution // Parameter pollution detected
|
||||||
|
|
||||||
|
// Authentication posture
|
||||||
|
cf.api_gateway.cf-risk-missing-auth // Endpoint lacks authentication
|
||||||
|
cf.api_gateway.cf-risk-mixed-auth // Inconsistent auth patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
## BOLA Detection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /user_schemas/{schema_id}/bola # Get BOLA config
|
||||||
|
PATCH /user_schemas/{schema_id}/bola # Update: {enabled:true}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Posture
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /discovery/authentication_posture # List unprotected endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
## GraphQL Protection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /settings/graphql_protection # Get limits
|
||||||
|
PUT /settings/graphql_protection # Set: {max_depth,max_size}
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [configuration.md](configuration.md) - Setup guides for all features
|
||||||
|
- [patterns.md](patterns.md) - Firewall rules and common patterns
|
||||||
|
- [API Gateway API Docs](https://developers.cloudflare.com/api/resources/api_gateway/)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
## Schema Validation 2.0 Setup
|
||||||
|
|
||||||
|
> ⚠️ **Classic Schema Validation deprecated.** Use Schema Validation 2.0.
|
||||||
|
|
||||||
|
**Upload schema (Dashboard):**
|
||||||
|
```
|
||||||
|
Security > API Shield > Schema Validation > Add validation
|
||||||
|
- Upload .yml/.yaml/.json (OpenAPI v3.0)
|
||||||
|
- Endpoints auto-added to Endpoint Management
|
||||||
|
- Action: Log | Block | None
|
||||||
|
- Body inspection: JSON payloads
|
||||||
|
```
|
||||||
|
|
||||||
|
**Change validation action:**
|
||||||
|
```
|
||||||
|
Security > API Shield > Settings > Schema Validation
|
||||||
|
Per-endpoint: Filter → ellipses → Change action
|
||||||
|
Default action: Set global mitigation action
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration from Classic:**
|
||||||
|
```
|
||||||
|
1. Export existing schema (if available)
|
||||||
|
2. Delete all Classic schema validation rules
|
||||||
|
3. Wait 5 min for cache clear
|
||||||
|
4. Re-upload via Schema Validation 2.0 interface
|
||||||
|
5. Verify in Security > Events
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fallthrough rule** (catch-all unknown endpoints):
|
||||||
|
```
|
||||||
|
Security > API Shield > Settings > Fallthrough > Use Template
|
||||||
|
- Select hostnames
|
||||||
|
- Create rule with cf.api_gateway.fallthrough_triggered
|
||||||
|
- Action: Log (discover) or Block (strict)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body inspection:** Supports `application/json`, `*/*`, `application/*`. Disable origin MIME sniffing to prevent bypasses.
|
||||||
|
|
||||||
|
## JWT Validation
|
||||||
|
|
||||||
|
**Setup token config:**
|
||||||
|
```
|
||||||
|
Security > API Shield > Settings > JWT Settings > Add configuration
|
||||||
|
- Name: "Auth0 JWT Config"
|
||||||
|
- Location: Header/Cookie + name (e.g., "Authorization")
|
||||||
|
- JWKS: Paste public keys from IdP
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create validation rule:**
|
||||||
|
```
|
||||||
|
Security > API Shield > API Rules > Add rule
|
||||||
|
- Hostname: api.example.com
|
||||||
|
- Deselect endpoints to ignore
|
||||||
|
- Token config: Select config
|
||||||
|
- Enforce presence: Ignore or Mark as non-compliant
|
||||||
|
- Action: Log/Block/Challenge
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rate limit by JWT claim:**
|
||||||
|
```wirefilter
|
||||||
|
lookup_json_string(http.request.jwt.claims["{config_id}"][0], "sub")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Special cases:**
|
||||||
|
- Two JWTs, different IdPs: Create 2 configs, select both, "Validate all"
|
||||||
|
- IdP migration: 2 configs + 2 rules, adjust actions per state
|
||||||
|
- Bearer prefix: API Shield handles with/without
|
||||||
|
- Nested claims: Dot notation `user.email`
|
||||||
|
|
||||||
|
## Mutual TLS (mTLS)
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
SSL/TLS > Client Certificates > Create Certificate
|
||||||
|
- Generate CF-managed CA (all plans)
|
||||||
|
- Upload custom CA (Enterprise, max 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configure mTLS rule:**
|
||||||
|
```
|
||||||
|
Security > API Shield > mTLS
|
||||||
|
- Select hostname(s)
|
||||||
|
- Choose certificate(s)
|
||||||
|
- Action: Block/Log/Challenge
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test:**
|
||||||
|
```bash
|
||||||
|
openssl req -x509 -newkey rsa:4096 -keyout client-key.pem -out client-cert.pem -days 365
|
||||||
|
curl https://api.example.com/endpoint --cert client-cert.pem --key client-key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Identifiers
|
||||||
|
|
||||||
|
Critical for BOLA Detection, Sequence Mitigation, and analytics. Configure header/cookie that uniquely IDs API users.
|
||||||
|
|
||||||
|
**Examples:** JWT sub claim, session token, API key, custom user ID header
|
||||||
|
|
||||||
|
**Configure:**
|
||||||
|
```
|
||||||
|
Security > API Shield > Settings > Session Identifiers
|
||||||
|
- Type: Header/Cookie
|
||||||
|
- Name: "X-User-ID" or "Authorization"
|
||||||
|
```
|
||||||
|
|
||||||
|
## BOLA Detection
|
||||||
|
|
||||||
|
Detects Broken Object Level Authorization attacks (enumeration + parameter pollution).
|
||||||
|
|
||||||
|
**Enable:**
|
||||||
|
```
|
||||||
|
Security > API Shield > Schema Validation > [Select Schema] > BOLA Detection
|
||||||
|
- Enable detection
|
||||||
|
- Threshold: Sensitivity level (Low/Medium/High)
|
||||||
|
- Action: Log or Block
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Schema Validation 2.0 enabled
|
||||||
|
- Session identifiers configured
|
||||||
|
- Minimum traffic: 1000+ requests/day per endpoint
|
||||||
|
|
||||||
|
## Authentication Posture
|
||||||
|
|
||||||
|
Identifies unprotected or inconsistently protected endpoints.
|
||||||
|
|
||||||
|
**View report:**
|
||||||
|
```
|
||||||
|
Security > API Shield > Authentication Posture
|
||||||
|
- Shows endpoints lacking JWT/mTLS
|
||||||
|
- Highlights mixed authentication patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remediate:**
|
||||||
|
1. Review flagged endpoints
|
||||||
|
2. Add JWT validation rules
|
||||||
|
3. Configure mTLS for sensitive endpoints
|
||||||
|
4. Monitor posture score
|
||||||
|
|
||||||
|
## Volumetric Abuse + GraphQL
|
||||||
|
|
||||||
|
**Volumetric Abuse Detection:**
|
||||||
|
`Security > API Shield > Settings > Volumetric Abuse Detection`
|
||||||
|
- Enable per-endpoint monitoring, set thresholds, action: Log | Challenge | Block
|
||||||
|
|
||||||
|
**GraphQL Protection:**
|
||||||
|
`Security > API Shield > Settings > GraphQL Protection`
|
||||||
|
- Max query depth: 10, max size: 100KB, block introspection (production)
|
||||||
|
|
||||||
|
## Terraform
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
# Session identifier
|
||||||
|
resource "cloudflare_api_shield" "main" {
|
||||||
|
zone_id = var.zone_id
|
||||||
|
auth_id_characteristics {
|
||||||
|
type = "header"
|
||||||
|
name = "Authorization"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add endpoint
|
||||||
|
resource "cloudflare_api_shield_operation" "users_get" {
|
||||||
|
zone_id = var.zone_id
|
||||||
|
method = "GET"
|
||||||
|
host = "api.example.com"
|
||||||
|
endpoint = "/api/users/{id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# JWT validation rule
|
||||||
|
resource "cloudflare_ruleset" "jwt_validation" {
|
||||||
|
zone_id = var.zone_id
|
||||||
|
name = "API JWT Validation"
|
||||||
|
kind = "zone"
|
||||||
|
phase = "http_request_firewall_custom"
|
||||||
|
|
||||||
|
rules {
|
||||||
|
action = "block"
|
||||||
|
expression = "(http.host eq \"api.example.com\" and not is_jwt_valid(http.request.jwt.payload[\"{config_id}\"][0]))"
|
||||||
|
description = "Block invalid JWTs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [api.md](api.md) - API endpoints and Workers integration
|
||||||
|
- [patterns.md](patterns.md) - Firewall rules and deployment patterns
|
||||||
|
- [gotchas.md](gotchas.md) - Troubleshooting and limits
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Gotchas & Troubleshooting
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### "Schema Validation 2.0 not working after migration"
|
||||||
|
|
||||||
|
**Cause:** Classic rules still active, conflicting with new system
|
||||||
|
**Solution:**
|
||||||
|
1. Delete ALL Classic schema validation rules
|
||||||
|
2. Clear Cloudflare cache (wait 5 min)
|
||||||
|
3. Re-upload schema via new Schema Validation 2.0 interface
|
||||||
|
4. Verify in Security > Events
|
||||||
|
5. Check action is set (Log/Block)
|
||||||
|
|
||||||
|
### "Schema validation blocking valid requests"
|
||||||
|
|
||||||
|
**Cause:** Schema too restrictive, missing fields, or incorrect types
|
||||||
|
**Solution:**
|
||||||
|
1. Check Firewall Events for violation details
|
||||||
|
2. Review schema in Settings
|
||||||
|
3. Test schema in Swagger Editor
|
||||||
|
4. Use Log mode to validate before blocking
|
||||||
|
5. Update schema with correct specifications
|
||||||
|
6. Ensure Schema Validation 2.0 (not Classic)
|
||||||
|
|
||||||
|
### "JWT validation failing"
|
||||||
|
|
||||||
|
**Cause:** JWKS mismatch with IdP, expired token, wrong header/cookie name, or clock skew
|
||||||
|
**Solution:**
|
||||||
|
1. Verify JWKS matches IdP configuration
|
||||||
|
2. Check token `exp` claim is valid
|
||||||
|
3. Confirm header/cookie name matches config
|
||||||
|
4. Test token at jwt.io
|
||||||
|
5. Account for clock skew (±5 min tolerance)
|
||||||
|
6. Use modern syntax: `is_jwt_valid(http.request.jwt.payload["{config_id}"][0])`
|
||||||
|
|
||||||
|
### "BOLA detection false positives"
|
||||||
|
|
||||||
|
**Cause:** Legitimate sequential access patterns, bulk operations, or sensitivity too high
|
||||||
|
**Solution:**
|
||||||
|
1. Review BOLA events in Security > Events
|
||||||
|
2. Lower sensitivity threshold (High → Medium → Low)
|
||||||
|
3. Exclude legitimate bulk operations from detection
|
||||||
|
4. Ensure session identifiers uniquely identify users
|
||||||
|
5. Verify minimum traffic requirements met (1000+ req/day)
|
||||||
|
|
||||||
|
### "Risk labels not appearing in firewall rules"
|
||||||
|
|
||||||
|
**Cause:** Feature not enabled, insufficient traffic, or missing session identifiers
|
||||||
|
**Solution:**
|
||||||
|
1. Verify Schema Validation 2.0 enabled
|
||||||
|
2. Enable BOLA Detection in schema settings
|
||||||
|
3. Configure session identifiers (required for BOLA)
|
||||||
|
4. Wait 24-48h for ML model training
|
||||||
|
5. Check minimum traffic thresholds met
|
||||||
|
|
||||||
|
### "Endpoint discovery not finding APIs"
|
||||||
|
|
||||||
|
**Cause:** Insufficient traffic (<500 reqs/10d), non-2xx responses, Worker direct requests, or incorrect session ID config
|
||||||
|
**Solution:** Ensure 500+ requests in 10 days, 2xx responses from edge (not Workers direct), configure session IDs correctly. ML updates daily.
|
||||||
|
|
||||||
|
### "Sequence detection false positives"
|
||||||
|
|
||||||
|
**Cause:** Lookback window issues, non-unique session IDs, or model sensitivity
|
||||||
|
**Solution:**
|
||||||
|
1. Review lookback settings (10 reqs to managed endpoints, 10min window)
|
||||||
|
2. Ensure session ID uniqueness per user (not shared tokens)
|
||||||
|
3. Adjust positive/negative model balance
|
||||||
|
4. Exclude legitimate workflows from detection
|
||||||
|
|
||||||
|
### "GraphQL protection blocking valid queries"
|
||||||
|
|
||||||
|
**Cause:** Query depth/size limits too restrictive, complex but legitimate queries
|
||||||
|
**Solution:**
|
||||||
|
1. Review blocked query patterns in Security > Events
|
||||||
|
2. Increase max_depth (default: 10) if needed
|
||||||
|
3. Increase max_size (default: 100KB) for complex queries
|
||||||
|
4. Whitelist specific query signatures
|
||||||
|
5. Use Log mode to tune before blocking
|
||||||
|
|
||||||
|
### "Token invalid"
|
||||||
|
|
||||||
|
**Cause:** Configuration error, JWKS mismatch, or expired token
|
||||||
|
**Solution:** Verify config matches IdP, update JWKS, check token expiration
|
||||||
|
|
||||||
|
### "Schema violation"
|
||||||
|
|
||||||
|
**Cause:** Missing required fields, wrong data types, or spec mismatch
|
||||||
|
**Solution:** Review schema against actual requests, ensure all required fields present, validate types match spec
|
||||||
|
|
||||||
|
### "Fallthrough"
|
||||||
|
|
||||||
|
**Cause:** Unknown endpoint or pattern mismatch
|
||||||
|
**Solution:** Update schema with all endpoints, check path pattern matching
|
||||||
|
|
||||||
|
### "mTLS failed"
|
||||||
|
|
||||||
|
**Cause:** Certificate untrusted/expired or wrong CA
|
||||||
|
**Solution:** Verify cert chain, check expiration, confirm correct CA uploaded
|
||||||
|
|
||||||
|
## Limits (2026)
|
||||||
|
|
||||||
|
| Resource/Limit | Value | Notes |
|
||||||
|
|----------------|-------|-------|
|
||||||
|
| OpenAPI version | v3.0.x only | No external refs, must be valid |
|
||||||
|
| Schema operations | 10K (Enterprise) | Contact for higher limits |
|
||||||
|
| JWT validation sources | Headers/cookies only | No query params/body |
|
||||||
|
| Endpoint discovery | 500+ reqs/10d | Minimum for ML model |
|
||||||
|
| Path normalization | Automatic | `/profile/238` → `/profile/{var1}` |
|
||||||
|
| Schema parameters | No `content` field | No object param validation |
|
||||||
|
| BOLA detection | 1000+ reqs/day/endpoint | Per-endpoint minimum |
|
||||||
|
| Session ID uniqueness | Required | BOLA/Sequence need unique IDs |
|
||||||
|
| GraphQL max depth | 1-50 | Default: 10 |
|
||||||
|
| GraphQL max size | 1KB-1MB | Default: 100KB |
|
||||||
|
| JWT claim nesting | 10 levels max | Use dot notation |
|
||||||
|
| mTLS CA certificates | 5 custom max | CF-managed unlimited |
|
||||||
|
| Schema upload size | 5MB max | Compressed OpenAPI spec |
|
||||||
|
| Volumetric abuse baseline | 7 days training | Initial ML period |
|
||||||
|
| Auth Posture refresh | Daily | Updated nightly |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [configuration.md](configuration.md) - Setup guides to avoid common issues
|
||||||
|
- [patterns.md](patterns.md) - Best practices and progressive rollout
|
||||||
|
- [API Shield Docs](https://developers.cloudflare.com/api-shield/)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Patterns & Use Cases
|
||||||
|
|
||||||
|
## Protect API with Schema + JWT
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Upload OpenAPI schema
|
||||||
|
POST /zones/{zone_id}/api_gateway/user_schemas
|
||||||
|
|
||||||
|
# 2. Configure JWT validation
|
||||||
|
POST /zones/{zone_id}/api_gateway/token_validation
|
||||||
|
{
|
||||||
|
"name": "Auth0",
|
||||||
|
"location": {"header": "Authorization"},
|
||||||
|
"jwks": "{...}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Create JWT rule
|
||||||
|
POST /zones/{zone_id}/api_gateway/jwt_validation_rules
|
||||||
|
|
||||||
|
# 4. Set schema validation action
|
||||||
|
PUT /zones/{zone_id}/api_gateway/settings/schema_validation
|
||||||
|
{"validation_default_mitigation_action": "block"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Progressive Rollout
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Log mode: Observe false positives
|
||||||
|
- Schema: Action = Log
|
||||||
|
- JWT: Action = Log
|
||||||
|
|
||||||
|
2. Block subset: Protect critical endpoints
|
||||||
|
- Change specific endpoint actions to Block
|
||||||
|
- Monitor firewall events
|
||||||
|
|
||||||
|
3. Full enforcement: Block all violations
|
||||||
|
- Change default action to Block
|
||||||
|
- Handle fallthrough with custom rule
|
||||||
|
```
|
||||||
|
|
||||||
|
## BOLA Detection
|
||||||
|
|
||||||
|
### Enumeration Detection
|
||||||
|
Detects sequential resource access (e.g., `/users/1`, `/users/2`, `/users/3`).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Block BOLA enumeration attempts
|
||||||
|
(cf.api_gateway.cf-risk-bola-enumeration and http.host eq "api.example.com")
|
||||||
|
// Action: Block or Challenge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameter Pollution
|
||||||
|
Detects duplicate/excessive parameters in requests.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Block parameter pollution
|
||||||
|
(cf.api_gateway.cf-risk-bola-pollution and http.host eq "api.example.com")
|
||||||
|
// Action: Block
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combined BOLA Protection
|
||||||
|
```javascript
|
||||||
|
// Comprehensive BOLA rule
|
||||||
|
(cf.api_gateway.cf-risk-bola-enumeration or cf.api_gateway.cf-risk-bola-pollution)
|
||||||
|
and http.host eq "api.example.com"
|
||||||
|
// Action: Block
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication Posture
|
||||||
|
|
||||||
|
### Detect Missing Auth
|
||||||
|
```javascript
|
||||||
|
// Log endpoints lacking authentication
|
||||||
|
(cf.api_gateway.cf-risk-missing-auth and http.host eq "api.example.com")
|
||||||
|
// Action: Log (for audit)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detect Mixed Auth
|
||||||
|
```javascript
|
||||||
|
// Alert on inconsistent auth patterns
|
||||||
|
(cf.api_gateway.cf-risk-mixed-auth and http.host eq "api.example.com")
|
||||||
|
// Action: Log (review required)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fallthrough Detection (Shadow APIs)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// WAF Custom Rule
|
||||||
|
(cf.api_gateway.fallthrough_triggered and http.host eq "api.example.com")
|
||||||
|
// Action: Log (discover unknown) or Block (strict)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting by User
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Rate Limiting Rule (modern syntax)
|
||||||
|
(http.host eq "api.example.com" and
|
||||||
|
is_jwt_valid(http.request.jwt.payload["{config_id}"][0]))
|
||||||
|
|
||||||
|
// Rate: 100 req/60s
|
||||||
|
// Counting expression: lookup_json_string(http.request.jwt.payload["{config_id}"][0], "sub")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volumetric Abuse Response
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Detect abnormal traffic spikes
|
||||||
|
(cf.api_gateway.volumetric_abuse_detected and http.host eq "api.example.com")
|
||||||
|
// Action: Challenge or Rate Limit
|
||||||
|
|
||||||
|
// Combined with rate limiting
|
||||||
|
(cf.api_gateway.volumetric_abuse_detected or
|
||||||
|
cf.threat_score gt 50) and http.host eq "api.example.com"
|
||||||
|
// Action: JS Challenge
|
||||||
|
```
|
||||||
|
|
||||||
|
## GraphQL Protection
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Block oversized queries
|
||||||
|
(http.request.uri.path eq "/graphql" and
|
||||||
|
cf.api_gateway.graphql_query_size gt 100000)
|
||||||
|
// Action: Block
|
||||||
|
|
||||||
|
// Block deep nested queries
|
||||||
|
(http.request.uri.path eq "/graphql" and
|
||||||
|
cf.api_gateway.graphql_query_depth gt 10)
|
||||||
|
// Action: Block
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
**Public API:** Discovery + Schema Validation 2.0 + JWT + Rate Limiting + Bot Management
|
||||||
|
**Partner API:** mTLS + Schema Validation + Sequence Mitigation
|
||||||
|
**Internal API:** Discovery + Schema Learning + Auth Posture
|
||||||
|
|
||||||
|
## OWASP API Security Top 10 Mapping (2026)
|
||||||
|
|
||||||
|
| OWASP Issue | API Shield Solutions |
|
||||||
|
|-------------|---------------------|
|
||||||
|
| API1:2023 Broken Object Level Authorization | **BOLA Detection** (enumeration + pollution), Sequence mitigation, Schema, JWT, Rate Limiting |
|
||||||
|
| API2:2023 Broken Authentication | **Auth Posture**, mTLS, JWT validation, Bot Management |
|
||||||
|
| API3:2023 Broken Object Property Auth | Schema validation, JWT validation |
|
||||||
|
| API4:2023 Unrestricted Resource Access | Rate Limiting, **Volumetric Abuse Detection**, **GraphQL Protection**, Bot Management |
|
||||||
|
| API5:2023 Broken Function Level Auth | Schema validation, JWT validation, Auth Posture |
|
||||||
|
| API6:2023 Unrestricted Business Flows | Sequence mitigation, Bot Management |
|
||||||
|
| API7:2023 SSRF | Schema validation, WAF managed rules |
|
||||||
|
| API8:2023 Security Misconfiguration | **Schema Validation 2.0**, Auth Posture, WAF rules |
|
||||||
|
| API9:2023 Improper Inventory Management | **API Discovery**, Schema learning, Auth Posture |
|
||||||
|
| API10:2023 Unsafe API Consumption | JWT validation, Schema validation, WAF managed |
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
**Security Events:** `Security > Events` → Filter: Action = block, Service = API Shield
|
||||||
|
**Firewall Analytics:** `Analytics > Security` → Filter by `cf.api_gateway.*` fields
|
||||||
|
**Logpush fields:** APIGatewayAuthIDPresent, APIGatewayRequestViolatesSchema, APIGatewayFallthroughDetected, JWTValidationResult
|
||||||
|
|
||||||
|
## Availability (2026)
|
||||||
|
|
||||||
|
| Feature | Availability | Notes |
|
||||||
|
|---------|-------------|-------|
|
||||||
|
| mTLS (CF-managed CA) | All plans | Self-service |
|
||||||
|
| Endpoint Management | All plans | Limited operations |
|
||||||
|
| Schema Validation 2.0 | All plans | Limited operations |
|
||||||
|
| API Discovery | Enterprise | 10K+ ops |
|
||||||
|
| JWT Validation | Enterprise add-on | Full validation |
|
||||||
|
| BOLA Detection | Enterprise add-on | Requires session IDs |
|
||||||
|
| Auth Posture | Enterprise add-on | Security audit |
|
||||||
|
| Volumetric Abuse Detection | Enterprise add-on | Traffic analysis |
|
||||||
|
| GraphQL Protection | Enterprise add-on | Query limits |
|
||||||
|
| Sequence Mitigation | Enterprise (beta) | Contact team |
|
||||||
|
| Full Suite | Enterprise add-on | All features |
|
||||||
|
|
||||||
|
**Enterprise limits:** 10K operations (contact for higher). Preview access available for non-contract evaluation.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [configuration.md](configuration.md) - Setup all features before creating rules
|
||||||
|
- [api.md](api.md) - Firewall field reference and API endpoints
|
||||||
|
- [gotchas.md](gotchas.md) - Common issues and limits
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Cloudflare API Integration
|
||||||
|
|
||||||
|
Guide for working with Cloudflare's REST API - authentication, SDK usage, common patterns, and troubleshooting.
|
||||||
|
|
||||||
|
## Quick Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
How are you calling the Cloudflare API?
|
||||||
|
├─ From Workers runtime → Use bindings, not REST API (see ../bindings/)
|
||||||
|
├─ Server-side (Node/Python/Go) → Official SDK (see api.md)
|
||||||
|
├─ CLI/scripts → Wrangler or curl (see configuration.md)
|
||||||
|
├─ Infrastructure-as-code → See ../pulumi/ or ../terraform/
|
||||||
|
└─ One-off requests → curl examples (see api.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
## SDK Selection
|
||||||
|
|
||||||
|
| Language | Package | Best For | Default Retries |
|
||||||
|
|----------|---------|----------|-----------------|
|
||||||
|
| TypeScript | `cloudflare` | Node.js, Bun, Next.js, Workers | 2 |
|
||||||
|
| Python | `cloudflare` | FastAPI, Django, scripts | 2 |
|
||||||
|
| Go | `cloudflare-go/v4` | CLI tools, microservices | 10 |
|
||||||
|
|
||||||
|
All SDKs are Stainless-generated from OpenAPI spec (consistent APIs).
|
||||||
|
|
||||||
|
## Authentication Methods
|
||||||
|
|
||||||
|
| Method | Security | Use Case | Scope |
|
||||||
|
|--------|----------|----------|-------|
|
||||||
|
| **API Token** ✓ | Scoped, rotatable | Production | Per-zone or account |
|
||||||
|
| API Key + Email | Full account access | Legacy only | Everything |
|
||||||
|
| User Service Key | Limited | Origin CA certs only | Origin CA |
|
||||||
|
|
||||||
|
**Always use API tokens** for new projects.
|
||||||
|
|
||||||
|
## Rate Limits
|
||||||
|
|
||||||
|
| Limit | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Per user/token | 1200 requests / 5 minutes |
|
||||||
|
| Per IP | 200 requests / second |
|
||||||
|
| GraphQL | 320 / 5 minutes (cost-based) |
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
| Task | Files to Read |
|
||||||
|
|------|---------------|
|
||||||
|
| Initialize SDK client | api.md |
|
||||||
|
| Configure auth/timeout/retry | configuration.md |
|
||||||
|
| Find usage patterns | patterns.md |
|
||||||
|
| Debug errors/rate limits | gotchas.md |
|
||||||
|
| Product-specific APIs | ../workers/, ../r2/, ../kv/, etc. |
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
- **[api.md](api.md)** - SDK client initialization, pagination, error handling, examples
|
||||||
|
- **[configuration.md](configuration.md)** - Environment variables, SDK config, Wrangler setup
|
||||||
|
- **[patterns.md](patterns.md)** - Real-world patterns, batch operations, workflows
|
||||||
|
- **[gotchas.md](gotchas.md)** - Rate limits, SDK-specific issues, troubleshooting
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Cloudflare API Docs](https://developers.cloudflare.com/api/)
|
||||||
|
- [Bindings Reference](../bindings/) - Workers runtime bindings (preferred over REST API)
|
||||||
|
- [Wrangler Reference](../wrangler/) - CLI tool for Cloudflare development
|
||||||
|
- [GraphQL Analytics API Reference](../graphql-api/) - Analytics data via GraphQL (separate endpoint from REST API)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
## Client Initialization
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Cloudflare from 'cloudflare';
|
||||||
|
|
||||||
|
const client = new Cloudflare({
|
||||||
|
apiToken: process.env.CLOUDFLARE_API_TOKEN,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from cloudflare import Cloudflare
|
||||||
|
|
||||||
|
client = Cloudflare(api_token=os.environ.get("CLOUDFLARE_API_TOKEN"))
|
||||||
|
|
||||||
|
# For async:
|
||||||
|
from cloudflare import AsyncCloudflare
|
||||||
|
client = AsyncCloudflare(api_token=os.environ["CLOUDFLARE_API_TOKEN"])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Go
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"github.com/cloudflare/cloudflare-go/v4"
|
||||||
|
"github.com/cloudflare/cloudflare-go/v4/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
client := cloudflare.NewClient(
|
||||||
|
option.WithAPIToken(os.Getenv("CLOUDFLARE_API_TOKEN")),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### API Token (Recommended)
|
||||||
|
|
||||||
|
**Create token**: Dashboard → My Profile → API Tokens → Create Token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CLOUDFLARE_API_TOKEN='your-token-here'
|
||||||
|
|
||||||
|
curl "https://api.cloudflare.com/client/v4/zones" \
|
||||||
|
--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token scopes**: Always use minimal permissions (zone-specific, time-limited).
|
||||||
|
|
||||||
|
### API Key (Legacy)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://api.cloudflare.com/client/v4/zones" \
|
||||||
|
--header "X-Auth-Email: user@example.com" \
|
||||||
|
--header "X-Auth-Key: $CLOUDFLARE_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Not recommended:** Full account access, cannot scope permissions.
|
||||||
|
|
||||||
|
## Auto-Pagination
|
||||||
|
|
||||||
|
All SDKs support automatic pagination for list operations.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TypeScript: for await...of
|
||||||
|
for await (const zone of client.zones.list()) {
|
||||||
|
console.log(zone.id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Python: iterator protocol
|
||||||
|
for zone in client.zones.list():
|
||||||
|
print(zone.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Go: ListAutoPaging
|
||||||
|
iter := client.Zones.ListAutoPaging(ctx, cloudflare.ZoneListParams{})
|
||||||
|
for iter.Next() {
|
||||||
|
zone := iter.Current()
|
||||||
|
fmt.Println(zone.ID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
const zone = await client.zones.get({ zone_id: 'xxx' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Cloudflare.NotFoundError) {
|
||||||
|
// 404
|
||||||
|
} else if (err instanceof Cloudflare.RateLimitError) {
|
||||||
|
// 429 - SDK auto-retries with backoff
|
||||||
|
} else if (err instanceof Cloudflare.APIError) {
|
||||||
|
console.log(err.status, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Error Types:**
|
||||||
|
- `AuthenticationError` (401) - Invalid token
|
||||||
|
- `PermissionDeniedError` (403) - Insufficient scope
|
||||||
|
- `NotFoundError` (404) - Resource not found
|
||||||
|
- `RateLimitError` (429) - Rate limit exceeded
|
||||||
|
- `InternalServerError` (≥500) - Cloudflare error
|
||||||
|
|
||||||
|
## Zone Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// List zones
|
||||||
|
const zones = await client.zones.list({
|
||||||
|
account: { id: 'account-id' },
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create zone
|
||||||
|
const zone = await client.zones.create({
|
||||||
|
account: { id: 'account-id' },
|
||||||
|
name: 'example.com',
|
||||||
|
type: 'full', // or 'partial'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update zone
|
||||||
|
await client.zones.edit('zone-id', {
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete zone
|
||||||
|
await client.zones.delete('zone-id');
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Go: requires cloudflare.F() wrapper
|
||||||
|
zone, err := client.Zones.New(ctx, cloudflare.ZoneNewParams{
|
||||||
|
Account: cloudflare.F(cloudflare.ZoneNewParamsAccount{
|
||||||
|
ID: cloudflare.F("account-id"),
|
||||||
|
}),
|
||||||
|
Name: cloudflare.F("example.com"),
|
||||||
|
Type: cloudflare.F(cloudflare.ZoneNewParamsTypeFull),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## DNS Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create DNS record
|
||||||
|
await client.dns.records.create({
|
||||||
|
zone_id: 'zone-id',
|
||||||
|
type: 'A',
|
||||||
|
name: 'subdomain.example.com',
|
||||||
|
content: '192.0.2.1',
|
||||||
|
ttl: 1, // auto
|
||||||
|
proxied: true, // Orange cloud
|
||||||
|
});
|
||||||
|
|
||||||
|
// List DNS records (with auto-pagination)
|
||||||
|
for await (const record of client.dns.records.list({
|
||||||
|
zone_id: 'zone-id',
|
||||||
|
type: 'A',
|
||||||
|
})) {
|
||||||
|
console.log(record.name, record.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DNS record
|
||||||
|
await client.dns.records.update({
|
||||||
|
zone_id: 'zone-id',
|
||||||
|
dns_record_id: 'record-id',
|
||||||
|
type: 'A',
|
||||||
|
name: 'subdomain.example.com',
|
||||||
|
content: '203.0.113.1',
|
||||||
|
proxied: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete DNS record
|
||||||
|
await client.dns.records.delete({
|
||||||
|
zone_id: 'zone-id',
|
||||||
|
dns_record_id: 'record-id',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Python example
|
||||||
|
client.dns.records.create(
|
||||||
|
zone_id="zone-id",
|
||||||
|
type="A",
|
||||||
|
name="subdomain.example.com",
|
||||||
|
content="192.0.2.1",
|
||||||
|
ttl=1,
|
||||||
|
proxied=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [configuration.md](./configuration.md) - SDK configuration, environment variables
|
||||||
|
- [patterns.md](./patterns.md) - Real-world patterns and workflows
|
||||||
|
- [gotchas.md](./gotchas.md) - Rate limits, troubleshooting
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Set Variables
|
||||||
|
|
||||||
|
| Platform | Command |
|
||||||
|
|----------|---------|
|
||||||
|
| Linux/macOS | `export CLOUDFLARE_API_TOKEN='token'` |
|
||||||
|
| PowerShell | `$env:CLOUDFLARE_API_TOKEN = 'token'` |
|
||||||
|
| Windows CMD | `set CLOUDFLARE_API_TOKEN=token` |
|
||||||
|
|
||||||
|
**Security:** Never commit tokens. Use `.env` files (gitignored) or secret managers.
|
||||||
|
|
||||||
|
### .env File Pattern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env (add to .gitignore)
|
||||||
|
CLOUDFLARE_API_TOKEN=your-token-here
|
||||||
|
CLOUDFLARE_ACCOUNT_ID=your-account-id
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TypeScript
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const client = new Cloudflare({
|
||||||
|
apiToken: process.env.CLOUDFLARE_API_TOKEN,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Python
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
client = Cloudflare(api_token=os.environ["CLOUDFLARE_API_TOKEN"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## SDK Configuration
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const client = new Cloudflare({
|
||||||
|
apiToken: process.env.CLOUDFLARE_API_TOKEN,
|
||||||
|
timeout: 120000, // 2 min (default 60s), in milliseconds
|
||||||
|
maxRetries: 5, // default 2
|
||||||
|
baseURL: 'https://...', // proxy (rare)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-request overrides
|
||||||
|
await client.zones.get(
|
||||||
|
{ zone_id: 'zone-id' },
|
||||||
|
{ timeout: 5000, maxRetries: 0 }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = Cloudflare(
|
||||||
|
api_token=os.environ["CLOUDFLARE_API_TOKEN"],
|
||||||
|
timeout=120, # seconds (default 60)
|
||||||
|
max_retries=5, # default 2
|
||||||
|
base_url="https://...", # proxy (rare)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-request overrides
|
||||||
|
client.with_options(timeout=5, max_retries=0).zones.get(zone_id="zone-id")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Go
|
||||||
|
|
||||||
|
```go
|
||||||
|
client := cloudflare.NewClient(
|
||||||
|
option.WithAPIToken(os.Getenv("CLOUDFLARE_API_TOKEN")),
|
||||||
|
option.WithMaxRetries(5), // default 10 (higher than TS/Python)
|
||||||
|
option.WithRequestTimeout(2 * time.Minute), // default 60s
|
||||||
|
option.WithBaseURL("https://..."), // proxy (rare)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-request overrides
|
||||||
|
client.Zones.Get(ctx, "zone-id", option.WithMaxRetries(0))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
| Option | TypeScript | Python | Go | Default |
|
||||||
|
|--------|-----------|--------|-----|---------|
|
||||||
|
| Timeout | `timeout` (ms) | `timeout` (s) | `WithRequestTimeout` | 60s |
|
||||||
|
| Retries | `maxRetries` | `max_retries` | `WithMaxRetries` | 2 (Go: 10) |
|
||||||
|
| Base URL | `baseURL` | `base_url` | `WithBaseURL` | api.cloudflare.com |
|
||||||
|
|
||||||
|
**Note:** Go SDK has higher default retries (10) than TypeScript/Python (2).
|
||||||
|
|
||||||
|
## Timeout Configuration
|
||||||
|
|
||||||
|
**When to increase:**
|
||||||
|
- Large zone transfers
|
||||||
|
- Bulk DNS operations
|
||||||
|
- Worker script uploads
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const client = new Cloudflare({
|
||||||
|
timeout: 300000, // 5 minutes
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Retry Configuration
|
||||||
|
|
||||||
|
**When to increase:** Rate-limit-heavy workflows, flaky network
|
||||||
|
|
||||||
|
**When to decrease:** Fast-fail requirements, user-facing requests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Increase retries for batch operations
|
||||||
|
const client = new Cloudflare({ maxRetries: 10 });
|
||||||
|
|
||||||
|
// Disable retries for fast-fail
|
||||||
|
const fastClient = new Cloudflare({ maxRetries: 0 });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wrangler CLI Integration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure authentication
|
||||||
|
wrangler login
|
||||||
|
# Or
|
||||||
|
export CLOUDFLARE_API_TOKEN='token'
|
||||||
|
|
||||||
|
# Common commands that use API
|
||||||
|
wrangler deploy # Uploads worker via API
|
||||||
|
wrangler kv:key put # KV operations
|
||||||
|
wrangler r2 bucket create # R2 operations
|
||||||
|
wrangler d1 execute # D1 operations
|
||||||
|
wrangler pages deploy # Pages operations
|
||||||
|
|
||||||
|
# Get API configuration
|
||||||
|
wrangler whoami # Shows authenticated user
|
||||||
|
```
|
||||||
|
|
||||||
|
### wrangler.toml
|
||||||
|
|
||||||
|
```toml
|
||||||
|
name = "my-worker"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2024-01-01"
|
||||||
|
account_id = "your-account-id"
|
||||||
|
|
||||||
|
# Can also use env vars:
|
||||||
|
# CLOUDFLARE_ACCOUNT_ID
|
||||||
|
# CLOUDFLARE_API_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [api.md](./api.md) - Client initialization, authentication
|
||||||
|
- [gotchas.md](./gotchas.md) - Rate limits, timeout errors
|
||||||
|
- [Wrangler Reference](../wrangler/) - CLI tool details
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# Gotchas & Troubleshooting
|
||||||
|
|
||||||
|
## Rate Limits & 429 Errors
|
||||||
|
|
||||||
|
**Actual Limits:**
|
||||||
|
- **1200 requests / 5 minutes** per user/token (global)
|
||||||
|
- **200 requests / second** per IP address
|
||||||
|
- **GraphQL: 320 / 5 minutes** (cost-based)
|
||||||
|
|
||||||
|
**SDK Behavior:**
|
||||||
|
- Auto-retry with exponential backoff (default 2 retries, Go: 10)
|
||||||
|
- Respects `Retry-After` header
|
||||||
|
- Throws `RateLimitError` after exhausting retries
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Increase retries for rate-limit-heavy workflows
|
||||||
|
const client = new Cloudflare({ maxRetries: 5 });
|
||||||
|
|
||||||
|
// Add application-level throttling
|
||||||
|
import pLimit from 'p-limit';
|
||||||
|
const limit = pLimit(10); // Max 10 concurrent requests
|
||||||
|
```
|
||||||
|
|
||||||
|
## SDK-Specific Issues
|
||||||
|
|
||||||
|
### Go: Required Field Wrapper
|
||||||
|
|
||||||
|
**Problem:** Go SDK requires `cloudflare.F()` wrapper for optional fields.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ❌ WRONG - Won't compile or send field
|
||||||
|
client.Zones.New(ctx, cloudflare.ZoneNewParams{
|
||||||
|
Name: "example.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ CORRECT
|
||||||
|
client.Zones.New(ctx, cloudflare.ZoneNewParams{
|
||||||
|
Name: cloudflare.F("example.com"),
|
||||||
|
Account: cloudflare.F(cloudflare.ZoneNewParamsAccount{
|
||||||
|
ID: cloudflare.F("account-id"),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Distinguishes between zero value, null, and omitted fields.
|
||||||
|
|
||||||
|
### Python: Async vs Sync Clients
|
||||||
|
|
||||||
|
**Problem:** Using sync client in async context or vice versa.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ❌ WRONG - Can't await sync client
|
||||||
|
from cloudflare import Cloudflare
|
||||||
|
client = Cloudflare()
|
||||||
|
await client.zones.list() # TypeError
|
||||||
|
|
||||||
|
# ✅ CORRECT - Use AsyncCloudflare
|
||||||
|
from cloudflare import AsyncCloudflare
|
||||||
|
client = AsyncCloudflare()
|
||||||
|
await client.zones.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Token Permission Errors (403)
|
||||||
|
|
||||||
|
**Problem:** API returns 403 Forbidden despite valid token.
|
||||||
|
|
||||||
|
**Cause:** Token lacks required permissions (scope).
|
||||||
|
|
||||||
|
**Scopes Required:**
|
||||||
|
|
||||||
|
| Operation | Required Scope |
|
||||||
|
|-----------|----------------|
|
||||||
|
| List zones | Zone:Read (zone-level or account-level) |
|
||||||
|
| Create zone | Zone:Edit (account-level) |
|
||||||
|
| Edit DNS | DNS:Edit (zone-level) |
|
||||||
|
| Deploy Worker | Workers Script:Edit (account-level) |
|
||||||
|
| Read KV | Workers KV Storage:Read |
|
||||||
|
| Write KV | Workers KV Storage:Edit |
|
||||||
|
|
||||||
|
**Solution:** Re-create token with correct permissions in Dashboard → My Profile → API Tokens.
|
||||||
|
|
||||||
|
## Pagination Truncation
|
||||||
|
|
||||||
|
**Problem:** Only getting first 20 results (default page size).
|
||||||
|
|
||||||
|
**Solution:** Use auto-pagination iterators.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG - Only first page (20 items)
|
||||||
|
const page = await client.zones.list();
|
||||||
|
|
||||||
|
// ✅ CORRECT - All results
|
||||||
|
const zones = [];
|
||||||
|
for await (const zone of client.zones.list()) {
|
||||||
|
zones.push(zone);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workers Subrequests
|
||||||
|
|
||||||
|
**Problem:** Rate limit hit faster than expected in Workers.
|
||||||
|
|
||||||
|
**Cause:** Workers subrequests count as separate API calls.
|
||||||
|
|
||||||
|
**Solution:** Use bindings instead of REST API in Workers (see ../bindings/).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG - REST API in Workers (counts against rate limit)
|
||||||
|
const client = new Cloudflare({ apiToken: env.CLOUDFLARE_API_TOKEN });
|
||||||
|
const zones = await client.zones.list();
|
||||||
|
|
||||||
|
// ✅ CORRECT - Use bindings (no rate limit)
|
||||||
|
// Access via env.MY_BINDING
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication Errors (401)
|
||||||
|
|
||||||
|
**Problem:** "Authentication failed" or "Invalid token"
|
||||||
|
|
||||||
|
**Causes:**
|
||||||
|
- Token expired
|
||||||
|
- Token deleted/revoked
|
||||||
|
- Token not set in environment
|
||||||
|
- Wrong token format
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Verify token is set
|
||||||
|
if (!process.env.CLOUDFLARE_API_TOKEN) {
|
||||||
|
throw new Error('CLOUDFLARE_API_TOKEN not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test token
|
||||||
|
const user = await client.user.tokens.verify();
|
||||||
|
console.log('Token valid:', user.status);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Timeout Errors
|
||||||
|
|
||||||
|
**Problem:** Request times out (default 60s).
|
||||||
|
|
||||||
|
**Cause:** Large operations (bulk DNS, zone transfers).
|
||||||
|
|
||||||
|
**Solution:** Increase timeout or split operations.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Increase timeout
|
||||||
|
const client = new Cloudflare({
|
||||||
|
timeout: 300000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or split operations
|
||||||
|
const batchSize = 100;
|
||||||
|
for (let i = 0; i < records.length; i += batchSize) {
|
||||||
|
const batch = records.slice(i, i + batchSize);
|
||||||
|
await processBatch(batch);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zone Not Found (404)
|
||||||
|
|
||||||
|
**Problem:** Zone ID valid but returns 404.
|
||||||
|
|
||||||
|
**Causes:**
|
||||||
|
- Zone not in account associated with token
|
||||||
|
- Zone deleted
|
||||||
|
- Wrong zone ID format
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// List all zones to find correct ID
|
||||||
|
for await (const zone of client.zones.list()) {
|
||||||
|
console.log(zone.id, zone.name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limits Reference
|
||||||
|
|
||||||
|
| Resource/Limit | Value | Notes |
|
||||||
|
|----------------|-------|-------|
|
||||||
|
| API rate limit | 1200/5min | Per user/token |
|
||||||
|
| IP rate limit | 200/sec | Per IP |
|
||||||
|
| GraphQL rate limit | 320/5min | Cost-based |
|
||||||
|
| Parallel requests (recommended) | < 10 | Avoid overwhelming API |
|
||||||
|
| Default page size | 20 | Use auto-pagination |
|
||||||
|
| Max page size | 50 | Some endpoints |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
**Security:**
|
||||||
|
- Never commit tokens
|
||||||
|
- Use minimal permissions
|
||||||
|
- Rotate tokens regularly
|
||||||
|
- Set token expiration
|
||||||
|
|
||||||
|
**Performance:**
|
||||||
|
- Batch operations
|
||||||
|
- Use pagination wisely
|
||||||
|
- Cache responses
|
||||||
|
- Handle rate limits
|
||||||
|
|
||||||
|
**Code Organization:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create reusable client instance
|
||||||
|
export const cfClient = new Cloudflare({
|
||||||
|
apiToken: process.env.CLOUDFLARE_API_TOKEN,
|
||||||
|
maxRetries: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap common operations
|
||||||
|
export async function getZoneDetails(zoneId: string) {
|
||||||
|
return await cfClient.zones.get({ zone_id: zoneId });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [api.md](./api.md) - Error types, authentication
|
||||||
|
- [configuration.md](./configuration.md) - Timeout/retry configuration
|
||||||
|
- [patterns.md](./patterns.md) - Error handling patterns
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# Common Patterns
|
||||||
|
|
||||||
|
## List All with Auto-Pagination
|
||||||
|
|
||||||
|
**Problem:** API returns paginated results. Default page size is 20.
|
||||||
|
|
||||||
|
**Solution:** Use SDK auto-pagination to iterate all results.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TypeScript
|
||||||
|
for await (const zone of client.zones.list()) {
|
||||||
|
console.log(zone.name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Python
|
||||||
|
for zone in client.zones.list():
|
||||||
|
print(zone.name)
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Go
|
||||||
|
iter := client.Zones.ListAutoPaging(ctx, cloudflare.ZoneListParams{})
|
||||||
|
for iter.Next() {
|
||||||
|
fmt.Println(iter.Current().Name)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling with Retry
|
||||||
|
|
||||||
|
**Problem:** Rate limits (429) and transient errors need retry.
|
||||||
|
|
||||||
|
**Solution:** SDKs auto-retry with exponential backoff. Customize as needed.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Increase retries for rate-limit-heavy operations
|
||||||
|
const client = new Cloudflare({ maxRetries: 5 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const zone = await client.zones.create({ /* ... */ });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Cloudflare.RateLimitError) {
|
||||||
|
// Already retried 5 times with backoff
|
||||||
|
const retryAfter = err.headers['retry-after'];
|
||||||
|
console.log(`Rate limited. Retry after ${retryAfter}s`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Parallel Operations
|
||||||
|
|
||||||
|
**Problem:** Need to create multiple resources quickly.
|
||||||
|
|
||||||
|
**Solution:** Use `Promise.all()` for parallel requests (respect rate limits).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create multiple DNS records in parallel
|
||||||
|
const records = ['www', 'api', 'cdn'].map(subdomain =>
|
||||||
|
client.dns.records.create({
|
||||||
|
zone_id: 'zone-id',
|
||||||
|
type: 'A',
|
||||||
|
name: `${subdomain}.example.com`,
|
||||||
|
content: '192.0.2.1',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.all(records);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Controlled concurrency** (avoid rate limits):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import pLimit from 'p-limit';
|
||||||
|
const limit = pLimit(10); // Max 10 concurrent
|
||||||
|
|
||||||
|
const subdomains = ['www', 'api', 'cdn', /* many more */];
|
||||||
|
const records = subdomains.map(subdomain =>
|
||||||
|
limit(() => client.dns.records.create({
|
||||||
|
zone_id: 'zone-id',
|
||||||
|
type: 'A',
|
||||||
|
name: `${subdomain}.example.com`,
|
||||||
|
content: '192.0.2.1',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await Promise.all(records);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zone CRUD Workflow
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create
|
||||||
|
const zone = await client.zones.create({
|
||||||
|
account: { id: 'account-id' },
|
||||||
|
name: 'example.com',
|
||||||
|
type: 'full',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read
|
||||||
|
const fetched = await client.zones.get({ zone_id: zone.id });
|
||||||
|
|
||||||
|
// Update
|
||||||
|
await client.zones.edit(zone.id, { paused: false });
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
await client.zones.delete(zone.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## DNS Bulk Update
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Fetch all A records
|
||||||
|
const records = [];
|
||||||
|
for await (const record of client.dns.records.list({
|
||||||
|
zone_id: 'zone-id',
|
||||||
|
type: 'A',
|
||||||
|
})) {
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all to new IP
|
||||||
|
await Promise.all(records.map(record =>
|
||||||
|
client.dns.records.update({
|
||||||
|
zone_id: 'zone-id',
|
||||||
|
dns_record_id: record.id,
|
||||||
|
type: 'A',
|
||||||
|
name: record.name,
|
||||||
|
content: '203.0.113.1', // New IP
|
||||||
|
proxied: record.proxied,
|
||||||
|
ttl: record.ttl,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Filter and Collect Results
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Find all proxied A records
|
||||||
|
const proxiedRecords = [];
|
||||||
|
for await (const record of client.dns.records.list({
|
||||||
|
zone_id: 'zone-id',
|
||||||
|
type: 'A',
|
||||||
|
})) {
|
||||||
|
if (record.proxied) {
|
||||||
|
proxiedRecords.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Recovery Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function createZoneWithRetry(name: string, maxAttempts = 3) {
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await client.zones.create({
|
||||||
|
account: { id: 'account-id' },
|
||||||
|
name,
|
||||||
|
type: 'full',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Cloudflare.RateLimitError && attempt < maxAttempts) {
|
||||||
|
const retryAfter = parseInt(err.headers['retry-after'] || '5');
|
||||||
|
console.log(`Rate limited, waiting ${retryAfter}s (retry ${attempt}/${maxAttempts})`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Update Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Only update if zone is active
|
||||||
|
const zone = await client.zones.get({ zone_id: 'zone-id' });
|
||||||
|
if (zone.status === 'active') {
|
||||||
|
await client.zones.edit(zone.id, { paused: false });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch with Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Process multiple zones, continue on errors
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
zoneIds.map(id => client.zones.get({ zone_id: id }))
|
||||||
|
);
|
||||||
|
|
||||||
|
results.forEach((result, i) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
console.log(`Zone ${i}: ${result.value.name}`);
|
||||||
|
} else {
|
||||||
|
console.error(`Zone ${i} failed:`, result.reason.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [api.md](./api.md) - SDK client initialization, basic operations
|
||||||
|
- [gotchas.md](./gotchas.md) - Rate limits, common errors
|
||||||
|
- [configuration.md](./configuration.md) - SDK configuration options
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Cloudflare Argo Smart Routing Skill Reference
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Cloudflare Argo Smart Routing is a performance optimization service that detects real-time network issues and routes web traffic across the most efficient network path. It continuously monitors network conditions and intelligently routes traffic through the fastest, most reliable routes in Cloudflare's network.
|
||||||
|
|
||||||
|
**Note on Smart Shield:** Argo Smart Routing is being integrated into Cloudflare's Smart Shield product for enhanced DDoS protection and performance. Existing Argo customers maintain full functionality with gradual migration to Smart Shield features.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Enable via cURL
|
||||||
|
```bash
|
||||||
|
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/{zone_id}/argo/smart_routing" \
|
||||||
|
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"value": "on"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable via TypeScript SDK
|
||||||
|
```typescript
|
||||||
|
import Cloudflare from 'cloudflare';
|
||||||
|
|
||||||
|
const client = new Cloudflare({ apiToken: process.env.CLOUDFLARE_API_TOKEN });
|
||||||
|
|
||||||
|
const result = await client.argo.smartRouting.edit({
|
||||||
|
zone_id: 'your-zone-id',
|
||||||
|
value: 'on',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Argo enabled: ${result.value}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### What It Does
|
||||||
|
- **Intelligent routing**: Detects congestion, outages, packet loss in real-time
|
||||||
|
- **Global optimization**: Routes across 300+ Cloudflare data centers
|
||||||
|
- **Automatic failover**: Switches paths when issues detected (typically <1s)
|
||||||
|
- **Works with existing setup**: No origin changes required
|
||||||
|
|
||||||
|
### Billing Model
|
||||||
|
- Usage-based: Charged per GB of traffic (excluding DDoS/WAF mitigated traffic)
|
||||||
|
- Requires billing configuration before enabling
|
||||||
|
- Available on Enterprise+ plans (check zone eligibility)
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- **High-traffic production sites** with global user base
|
||||||
|
- **Latency-sensitive applications** (APIs, real-time services)
|
||||||
|
- **Sites behind Cloudflare proxy** (orange-clouded DNS records)
|
||||||
|
- **Combined with Tiered Cache** for maximum performance gains
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
- Development/staging environments (cost control)
|
||||||
|
- Low-traffic sites (<1TB/month) where cost may exceed benefit
|
||||||
|
- Sites with primarily single-region traffic
|
||||||
|
|
||||||
|
## Should I Enable Argo?
|
||||||
|
|
||||||
|
| Your Situation | Recommendation |
|
||||||
|
|----------------|----------------|
|
||||||
|
| Global production app, >1TB/month traffic | ✅ Enable - likely ROI positive |
|
||||||
|
| Enterprise plan, latency-critical APIs | ✅ Enable - performance matters |
|
||||||
|
| Regional site, <100GB/month traffic | ⚠️ Evaluate - cost may not justify |
|
||||||
|
| Development/staging environment | ❌ Disable - use in production only |
|
||||||
|
| Not yet configured billing | ❌ Configure billing first |
|
||||||
|
|
||||||
|
## Reading Order by Task
|
||||||
|
|
||||||
|
| Your Goal | Start With | Then Read |
|
||||||
|
|-----------|------------|-----------|
|
||||||
|
| Enable Argo for first time | Quick Start above → [configuration.md](configuration.md) | [gotchas.md](gotchas.md) |
|
||||||
|
| Use TypeScript/Python SDK | [api.md](api.md) | [patterns.md](patterns.md) |
|
||||||
|
| Terraform/IaC setup | [configuration.md](configuration.md) | - |
|
||||||
|
| Enable for Spectrum TCP app | [patterns.md](patterns.md) → Spectrum section | [api.md](api.md) |
|
||||||
|
| Troubleshoot enablement issue | [gotchas.md](gotchas.md) | [api.md](api.md) |
|
||||||
|
| Manage billing/usage | [patterns.md](patterns.md) → Billing section | [gotchas.md](gotchas.md) |
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
- **[api.md](api.md)** - API endpoints, SDK methods, error handling, Python/TypeScript examples
|
||||||
|
- **[configuration.md](configuration.md)** - Terraform setup, environment config, billing configuration
|
||||||
|
- **[patterns.md](patterns.md)** - Tiered Cache integration, Spectrum TCP apps, billing management, validation patterns
|
||||||
|
- **[gotchas.md](gotchas.md)** - Common errors, permission issues, limits, best practices
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Cloudflare Argo Smart Routing Docs](https://developers.cloudflare.com/argo-smart-routing/)
|
||||||
|
- [Cloudflare Smart Shield](https://developers.cloudflare.com/smart-shield/)
|
||||||
|
- [Spectrum Documentation](https://developers.cloudflare.com/spectrum/)
|
||||||
|
- [Tiered Cache](https://developers.cloudflare.com/cache/how-to/tiered-cache/)
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
## API Reference
|
||||||
|
|
||||||
|
**Note on Smart Shield:** Argo Smart Routing is being integrated into Cloudflare's Smart Shield product. API endpoints remain stable; existing integrations continue to work without changes.
|
||||||
|
|
||||||
|
### Base Endpoint
|
||||||
|
```
|
||||||
|
https://api.cloudflare.com/client/v4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
Use API tokens with Zone:Argo Smart Routing:Edit permissions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Headers required
|
||||||
|
X-Auth-Email: user@example.com
|
||||||
|
Authorization: Bearer YOUR_API_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Argo Smart Routing Status
|
||||||
|
|
||||||
|
**Endpoint:** `GET /zones/{zone_id}/argo/smart_routing`
|
||||||
|
|
||||||
|
**Description:** Retrieves current Argo Smart Routing enablement status.
|
||||||
|
|
||||||
|
**cURL Example:**
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://api.cloudflare.com/client/v4/zones/{zone_id}/argo/smart_routing" \
|
||||||
|
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"id": "smart_routing",
|
||||||
|
"value": "on",
|
||||||
|
"editable": true,
|
||||||
|
"modified_on": "2024-01-11T12:00:00Z"
|
||||||
|
},
|
||||||
|
"success": true,
|
||||||
|
"errors": [],
|
||||||
|
"messages": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript SDK Example:**
|
||||||
|
```typescript
|
||||||
|
import Cloudflare from 'cloudflare';
|
||||||
|
|
||||||
|
const client = new Cloudflare({
|
||||||
|
apiToken: process.env.CLOUDFLARE_API_TOKEN
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await client.argo.smartRouting.get({ zone_id: 'your-zone-id' });
|
||||||
|
console.log(`Argo status: ${status.value}, editable: ${status.editable}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python SDK Example:**
|
||||||
|
```python
|
||||||
|
from cloudflare import Cloudflare
|
||||||
|
|
||||||
|
client = Cloudflare(api_token=os.environ.get('CLOUDFLARE_API_TOKEN'))
|
||||||
|
|
||||||
|
status = client.argo.smart_routing.get(zone_id='your-zone-id')
|
||||||
|
print(f"Argo status: {status.value}, editable: {status.editable}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Argo Smart Routing Status
|
||||||
|
|
||||||
|
**Endpoint:** `PATCH /zones/{zone_id}/argo/smart_routing`
|
||||||
|
|
||||||
|
**Description:** Enable or disable Argo Smart Routing for a zone.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"value": "on" // or "off"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**cURL Example:**
|
||||||
|
```bash
|
||||||
|
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/{zone_id}/argo/smart_routing" \
|
||||||
|
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"value": "on"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript SDK Example:**
|
||||||
|
```typescript
|
||||||
|
const result = await client.argo.smartRouting.edit({
|
||||||
|
zone_id: 'your-zone-id',
|
||||||
|
value: 'on',
|
||||||
|
});
|
||||||
|
console.log(`Updated: ${result.value} at ${result.modified_on}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python SDK Example:**
|
||||||
|
```python
|
||||||
|
result = client.argo.smart_routing.edit(
|
||||||
|
zone_id='your-zone-id',
|
||||||
|
value='on'
|
||||||
|
)
|
||||||
|
print(f"Updated: {result.value} at {result.modified_on}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checking Editability Before Updates
|
||||||
|
|
||||||
|
**Critical:** Always check the `editable` field before attempting to enable/disable Argo. When `editable: false`, the zone has restrictions (billing not configured, insufficient permissions, or plan limitations).
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```typescript
|
||||||
|
async function safelyEnableArgo(client: Cloudflare, zoneId: string): Promise<boolean> {
|
||||||
|
const status = await client.argo.smartRouting.get({ zone_id: zoneId });
|
||||||
|
|
||||||
|
if (!status.editable) {
|
||||||
|
console.error('Cannot modify Argo: editable=false (check billing/permissions)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.value === 'on') {
|
||||||
|
console.log('Argo already enabled');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.argo.smartRouting.edit({ zone_id: zoneId, value: 'on' });
|
||||||
|
console.log('Argo enabled successfully');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python Pattern:**
|
||||||
|
```python
|
||||||
|
def safely_enable_argo(client: Cloudflare, zone_id: str) -> bool:
|
||||||
|
status = client.argo.smart_routing.get(zone_id=zone_id)
|
||||||
|
|
||||||
|
if not status.editable:
|
||||||
|
print('Cannot modify Argo: editable=false (check billing/permissions)')
|
||||||
|
return False
|
||||||
|
|
||||||
|
if status.value == 'on':
|
||||||
|
print('Argo already enabled')
|
||||||
|
return True
|
||||||
|
|
||||||
|
client.argo.smart_routing.edit(zone_id=zone_id, value='on')
|
||||||
|
print('Argo enabled successfully')
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The TypeScript SDK provides typed error classes for robust error handling:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Cloudflare from 'cloudflare';
|
||||||
|
import { APIError, APIConnectionError, RateLimitError } from 'cloudflare';
|
||||||
|
|
||||||
|
async function enableArgoWithErrorHandling(client: Cloudflare, zoneId: string) {
|
||||||
|
try {
|
||||||
|
const result = await client.argo.smartRouting.edit({
|
||||||
|
zone_id: zoneId,
|
||||||
|
value: 'on',
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RateLimitError) {
|
||||||
|
console.error('Rate limited. Retry after:', error.response?.headers.get('retry-after'));
|
||||||
|
// Implement exponential backoff
|
||||||
|
} else if (error instanceof APIError) {
|
||||||
|
console.error('API error:', error.status, error.message);
|
||||||
|
if (error.status === 403) {
|
||||||
|
console.error('Permission denied - check API token scopes');
|
||||||
|
} else if (error.status === 400) {
|
||||||
|
console.error('Bad request - verify zone_id and payload');
|
||||||
|
}
|
||||||
|
} else if (error instanceof APIConnectionError) {
|
||||||
|
console.error('Connection failed:', error.message);
|
||||||
|
// Retry with exponential backoff
|
||||||
|
} else {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python Error Handling:**
|
||||||
|
```python
|
||||||
|
from cloudflare import Cloudflare, APIError, RateLimitError
|
||||||
|
|
||||||
|
def enable_argo_with_error_handling(client: Cloudflare, zone_id: str):
|
||||||
|
try:
|
||||||
|
result = client.argo.smart_routing.edit(zone_id=zone_id, value='on')
|
||||||
|
return result
|
||||||
|
except RateLimitError as e:
|
||||||
|
print(f"Rate limited. Retry after: {e.response.headers.get('retry-after')}")
|
||||||
|
raise
|
||||||
|
except APIError as e:
|
||||||
|
print(f"API error: {e.status} - {e.message}")
|
||||||
|
if e.status == 403:
|
||||||
|
print('Permission denied - check API token scopes')
|
||||||
|
elif e.status == 400:
|
||||||
|
print('Bad request - verify zone_id and payload')
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {e}")
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Schema
|
||||||
|
|
||||||
|
All Argo Smart Routing API responses follow this structure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ArgoSmartRoutingResponse {
|
||||||
|
result: {
|
||||||
|
id: 'smart_routing';
|
||||||
|
value: 'on' | 'off';
|
||||||
|
editable: boolean;
|
||||||
|
modified_on: string; // ISO 8601 timestamp
|
||||||
|
};
|
||||||
|
success: boolean;
|
||||||
|
errors: Array<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
|
messages: Array<string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Response Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `value` | `"on" \| "off"` | Current enablement status |
|
||||||
|
| `editable` | `boolean` | Whether changes are allowed (check before PATCH) |
|
||||||
|
| `modified_on` | `string` | ISO timestamp of last modification |
|
||||||
|
| `success` | `boolean` | Whether request succeeded |
|
||||||
|
| `errors` | `Array` | Error details if `success: false`
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
## Configuration Management
|
||||||
|
|
||||||
|
**Note on Smart Shield Evolution:** Argo Smart Routing is being integrated into Smart Shield. Configuration methods below remain valid; Terraform and IaC patterns unchanged.
|
||||||
|
|
||||||
|
### Infrastructure as Code (Terraform)
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
# terraform/argo.tf
|
||||||
|
# Note: Use Cloudflare Terraform provider
|
||||||
|
|
||||||
|
resource "cloudflare_argo" "example" {
|
||||||
|
zone_id = var.zone_id
|
||||||
|
smart_routing = "on"
|
||||||
|
tiered_caching = "on"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "zone_id" {
|
||||||
|
description = "Cloudflare Zone ID"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
output "argo_enabled" {
|
||||||
|
value = cloudflare_argo.example.smart_routing
|
||||||
|
description = "Argo Smart Routing status"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment-Based Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// config/argo.ts
|
||||||
|
interface ArgoEnvironmentConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
tieredCache: boolean;
|
||||||
|
monitoring: {
|
||||||
|
usageAlerts: boolean;
|
||||||
|
threshold: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const configs: Record<string, ArgoEnvironmentConfig> = {
|
||||||
|
production: {
|
||||||
|
enabled: true,
|
||||||
|
tieredCache: true,
|
||||||
|
monitoring: {
|
||||||
|
usageAlerts: true,
|
||||||
|
threshold: 1000, // GB
|
||||||
|
},
|
||||||
|
},
|
||||||
|
staging: {
|
||||||
|
enabled: true,
|
||||||
|
tieredCache: false,
|
||||||
|
monitoring: {
|
||||||
|
usageAlerts: false,
|
||||||
|
threshold: 100, // GB
|
||||||
|
},
|
||||||
|
},
|
||||||
|
development: {
|
||||||
|
enabled: false,
|
||||||
|
tieredCache: false,
|
||||||
|
monitoring: {
|
||||||
|
usageAlerts: false,
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getArgoConfig(env: string): ArgoEnvironmentConfig {
|
||||||
|
return configs[env] || configs.development;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pulumi Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// pulumi/argo.ts
|
||||||
|
import * as cloudflare from '@pulumi/cloudflare';
|
||||||
|
|
||||||
|
const zone = new cloudflare.Zone('example-zone', {
|
||||||
|
zone: 'example.com',
|
||||||
|
plan: 'enterprise',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argoSettings = new cloudflare.Argo('argo-config', {
|
||||||
|
zoneId: zone.id,
|
||||||
|
smartRouting: 'on',
|
||||||
|
tieredCaching: 'on',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const argoEnabled = argoSettings.smartRouting;
|
||||||
|
export const zoneId = zone.id;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Billing Configuration
|
||||||
|
|
||||||
|
Before enabling Argo Smart Routing, ensure billing is configured for the account:
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
1. Valid payment method on file
|
||||||
|
2. Enterprise or higher plan
|
||||||
|
3. Zone must have billing enabled
|
||||||
|
|
||||||
|
**Check Billing Status via Dashboard:**
|
||||||
|
1. Navigate to Account → Billing
|
||||||
|
2. Verify payment method configured
|
||||||
|
3. Check zone subscription status
|
||||||
|
|
||||||
|
**Note:** Attempting to enable Argo without billing configured will result in `editable: false` in API responses.
|
||||||
|
|
||||||
|
## Environment Variable Setup
|
||||||
|
|
||||||
|
**Required Environment Variables:**
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
CLOUDFLARE_API_TOKEN=your_api_token_here
|
||||||
|
CLOUDFLARE_ZONE_ID=your_zone_id_here
|
||||||
|
CLOUDFLARE_ACCOUNT_ID=your_account_id_here
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
ARGO_ENABLED=true
|
||||||
|
ARGO_TIERED_CACHE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript Configuration Loader:**
|
||||||
|
```typescript
|
||||||
|
// config/env.ts
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
CLOUDFLARE_API_TOKEN: z.string().min(1),
|
||||||
|
CLOUDFLARE_ZONE_ID: z.string().min(1),
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: z.string().min(1),
|
||||||
|
ARGO_ENABLED: z.string().optional().default('false'),
|
||||||
|
ARGO_TIERED_CACHE: z.string().optional().default('false'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const env = envSchema.parse(process.env);
|
||||||
|
|
||||||
|
export const argoConfig = {
|
||||||
|
enabled: env.ARGO_ENABLED === 'true',
|
||||||
|
tieredCache: env.ARGO_TIERED_CACHE === 'true',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
**GitHub Actions Example:**
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/deploy-argo.yml
|
||||||
|
name: Deploy Argo Configuration
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'terraform/argo.tf'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Terraform
|
||||||
|
uses: hashicorp/setup-terraform@v2
|
||||||
|
|
||||||
|
- name: Terraform Init
|
||||||
|
run: terraform init
|
||||||
|
working-directory: ./terraform
|
||||||
|
|
||||||
|
- name: Terraform Apply
|
||||||
|
run: terraform apply -auto-approve
|
||||||
|
working-directory: ./terraform
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
TF_VAR_zone_id: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enterprise Preview Program
|
||||||
|
|
||||||
|
For early access to Argo Smart Routing features and Smart Shield integration:
|
||||||
|
|
||||||
|
**Eligibility:**
|
||||||
|
- Enterprise plan customers
|
||||||
|
- Active Cloudflare support contract
|
||||||
|
- Production traffic >100GB/month
|
||||||
|
|
||||||
|
**How to Join:**
|
||||||
|
1. Contact Cloudflare account team or support
|
||||||
|
2. Request Argo/Smart Shield preview access
|
||||||
|
3. Receive preview zone configuration
|
||||||
|
|
||||||
|
**Preview Features:**
|
||||||
|
- Enhanced analytics and reporting
|
||||||
|
- Smart Shield DDoS integration
|
||||||
|
- Advanced routing policies
|
||||||
|
- Priority support for routing issues
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
## Best Practices Summary
|
||||||
|
|
||||||
|
**Smart Shield Note:** Argo Smart Routing evolving into Smart Shield. Best practices below remain applicable; monitor Cloudflare changelog for Smart Shield updates.
|
||||||
|
|
||||||
|
1. **Always check editability** before attempting to enable/disable Argo
|
||||||
|
2. **Set up billing notifications** to avoid unexpected costs
|
||||||
|
3. **Combine with Tiered Cache** for maximum performance benefit
|
||||||
|
4. **Use in production only** - disable for dev/staging to control costs
|
||||||
|
5. **Monitor analytics** - require 500+ requests in 48h for detailed metrics
|
||||||
|
6. **Handle errors gracefully** - check for billing, permissions, zone compatibility
|
||||||
|
7. **Test configuration changes** in staging before production
|
||||||
|
8. **Use TypeScript SDK** for type safety and better developer experience
|
||||||
|
9. **Implement retry logic** for API calls in production systems
|
||||||
|
10. **Document zone-specific settings** for team visibility
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### "Argo unavailable"
|
||||||
|
|
||||||
|
**Problem:** API returns error "Argo Smart Routing is unavailable for this zone"
|
||||||
|
|
||||||
|
**Cause:** Zone not eligible or billing not set up
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Verify zone has Enterprise or higher plan
|
||||||
|
2. Check billing is configured in Account → Billing
|
||||||
|
3. Ensure payment method is valid and current
|
||||||
|
4. Contact Cloudflare support if eligibility unclear
|
||||||
|
|
||||||
|
### "Cannot enable/disable"
|
||||||
|
|
||||||
|
**Problem:** API call succeeds but status remains unchanged, or `editable: false` in GET response
|
||||||
|
|
||||||
|
**Cause:** Insufficient permissions or zone restrictions
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check API token has `Zone:Argo Smart Routing:Edit` permission
|
||||||
|
2. Verify `editable: true` in GET response before attempting PATCH
|
||||||
|
3. If `editable: false`, check:
|
||||||
|
- Billing configured for account
|
||||||
|
- Zone plan includes Argo (Enterprise+)
|
||||||
|
- No active zone holds or suspensions
|
||||||
|
- API token has correct scopes
|
||||||
|
|
||||||
|
### `editable: false` Error
|
||||||
|
|
||||||
|
**Problem:** GET request returns `"editable": false`, preventing enable/disable
|
||||||
|
|
||||||
|
**Cause:** Zone-level restrictions from billing, plan, or permissions
|
||||||
|
|
||||||
|
**Solution Pattern:**
|
||||||
|
```typescript
|
||||||
|
const status = await client.argo.smartRouting.get({ zone_id: zoneId });
|
||||||
|
|
||||||
|
if (!status.editable) {
|
||||||
|
// Don't attempt to modify - will fail
|
||||||
|
console.error('Cannot modify Argo settings:');
|
||||||
|
console.error('- Check billing is configured');
|
||||||
|
console.error('- Verify zone has Enterprise+ plan');
|
||||||
|
console.error('- Confirm API token has Edit permission');
|
||||||
|
throw new Error('Argo is not editable for this zone');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe to proceed with enable/disable
|
||||||
|
await client.argo.smartRouting.edit({ zone_id: zoneId, value: 'on' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
**Problem:** `429 Too Many Requests` error from API
|
||||||
|
|
||||||
|
**Cause:** Exceeded API rate limits (typically 1200 requests per 5 minutes)
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```typescript
|
||||||
|
import { RateLimitError } from 'cloudflare';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.argo.smartRouting.edit({ zone_id: zoneId, value: 'on' });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RateLimitError) {
|
||||||
|
const retryAfter = error.response?.headers.get('retry-after');
|
||||||
|
console.log(`Rate limited. Retry after ${retryAfter} seconds`);
|
||||||
|
|
||||||
|
// Implement exponential backoff
|
||||||
|
await new Promise(resolve => setTimeout(resolve, (retryAfter || 60) * 1000));
|
||||||
|
// Retry request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
| Resource/Limit | Value | Notes |
|
||||||
|
|----------------|-------|-------|
|
||||||
|
| Min requests for analytics | 500 in 48h | For detailed metrics via GraphQL |
|
||||||
|
| Zones supported | Enterprise+ | Check zone plan in dashboard |
|
||||||
|
| Billing requirement | Must be configured | Before enabling; verify payment method |
|
||||||
|
| API rate limit | 1200 req / 5 min | Per API token across all endpoints |
|
||||||
|
| Spectrum apps | No hard limit | Each app can enable Argo independently |
|
||||||
|
| Traffic counting | Proxied only | Only orange-clouded DNS records count |
|
||||||
|
| DDoS/WAF exemption | Yes | Mitigated traffic excluded from billing |
|
||||||
|
| Analytics latency | 1-5 minutes | Real-time metrics not available |
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Official Argo Smart Routing Docs](https://developers.cloudflare.com/argo-smart-routing/)
|
||||||
|
- [Cloudflare Smart Shield](https://developers.cloudflare.com/smart-shield/)
|
||||||
|
- [API Authentication](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/)
|
||||||
|
- [Cloudflare TypeScript SDK](https://github.com/cloudflare/cloudflare-typescript)
|
||||||
|
- [Cloudflare Python SDK](https://github.com/cloudflare/cloudflare-python)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Integration Patterns
|
||||||
|
|
||||||
|
## Enable Argo + Tiered Cache
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function enableOptimalPerformance(client: Cloudflare, zoneId: string) {
|
||||||
|
await Promise.all([
|
||||||
|
client.argo.smartRouting.edit({ zone_id: zoneId, value: 'on' }),
|
||||||
|
client.argo.tieredCaching.edit({ zone_id: zoneId, value: 'on' }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow:** Visitor → Edge (Lower-Tier) → [Cache Miss] → Upper-Tier → [Cache Miss + Argo] → Origin
|
||||||
|
|
||||||
|
**Impact:** Argo ~30% latency reduction + Tiered Cache 50-80% origin offload
|
||||||
|
|
||||||
|
## Usage Analytics (GraphQL)
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query ArgoAnalytics($zoneTag: string!) {
|
||||||
|
viewer {
|
||||||
|
zones(filter: { zoneTag: $zoneTag }) {
|
||||||
|
httpRequestsAdaptiveGroups(limit: 1000) {
|
||||||
|
sum { argoBytes, bytes }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Billing:** ~$0.10/GB. DDoS-mitigated and WAF-blocked traffic NOT charged.
|
||||||
|
|
||||||
|
## Spectrum TCP Integration
|
||||||
|
|
||||||
|
Enable Argo for non-HTTP traffic (databases, game servers, IoT):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Update existing app
|
||||||
|
await client.spectrum.apps.update(appId, { zone_id: zoneId, argo_smart_routing: true });
|
||||||
|
|
||||||
|
// Create new app with Argo
|
||||||
|
await client.spectrum.apps.create({
|
||||||
|
zone_id: zoneId,
|
||||||
|
dns: { type: 'CNAME', name: 'tcp.example.com' },
|
||||||
|
origin_direct: ['tcp://origin.example.com:3306'],
|
||||||
|
protocol: 'tcp/3306',
|
||||||
|
argo_smart_routing: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:** MySQL/PostgreSQL (3306/5432), game servers, MQTT (1883), SSH (22)
|
||||||
|
|
||||||
|
## Pre-Flight Validation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function validateArgoEligibility(client: Cloudflare, zoneId: string) {
|
||||||
|
const status = await client.argo.smartRouting.get({ zone_id: zoneId });
|
||||||
|
const zone = await client.zones.get({ zone_id: zoneId });
|
||||||
|
|
||||||
|
const issues: string[] = [];
|
||||||
|
if (!status.editable) issues.push('Zone not editable');
|
||||||
|
if (['free', 'pro'].includes(zone.plan.legacy_id)) issues.push('Requires Business+ plan');
|
||||||
|
if (zone.status !== 'active') issues.push('Zone not active');
|
||||||
|
|
||||||
|
return { canEnable: issues.length === 0, issues };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-Enable Verification
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function verifyArgoEnabled(client: Cloudflare, zoneId: string): Promise<boolean> {
|
||||||
|
await new Promise(r => setTimeout(r, 2000)); // Wait for propagation
|
||||||
|
const status = await client.argo.smartRouting.get({ zone_id: zoneId });
|
||||||
|
return status.value === 'on';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Setup Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function setupArgo(client: Cloudflare, zoneId: string) {
|
||||||
|
// 1. Validate
|
||||||
|
const { canEnable, issues } = await validateArgoEligibility(client, zoneId);
|
||||||
|
if (!canEnable) throw new Error(issues.join(', '));
|
||||||
|
|
||||||
|
// 2. Enable both features
|
||||||
|
await Promise.all([
|
||||||
|
client.argo.smartRouting.edit({ zone_id: zoneId, value: 'on' }),
|
||||||
|
client.argo.tieredCaching.edit({ zone_id: zoneId, value: 'on' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Verify
|
||||||
|
const [argo, cache] = await Promise.all([
|
||||||
|
client.argo.smartRouting.get({ zone_id: zoneId }),
|
||||||
|
client.argo.tieredCaching.get({ zone_id: zoneId }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { argo: argo.value === 'on', tieredCache: cache.value === 'on' };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to combine:** High-traffic sites (>1TB/mo), global users, cacheable content.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Cloudflare Bindings Skill Reference
|
||||||
|
|
||||||
|
Expert guidance on Cloudflare Workers Bindings - the runtime APIs that connect Workers to Cloudflare platform resources.
|
||||||
|
|
||||||
|
## What Are Bindings?
|
||||||
|
|
||||||
|
Bindings are how Workers access Cloudflare resources (storage, compute, services) via the `env` object. They're configured in `wrangler.jsonc`, type-safe via TypeScript, and zero-overhead at runtime.
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
1. **This file** - Binding catalog and selection guide
|
||||||
|
2. **[api.md](api.md)** - TypeScript types and env access patterns
|
||||||
|
3. **[configuration.md](configuration.md)** - Complete wrangler.jsonc examples
|
||||||
|
4. **[patterns.md](patterns.md)** - Best practices and common patterns
|
||||||
|
5. **[gotchas.md](gotchas.md)** - Critical pitfalls and troubleshooting
|
||||||
|
|
||||||
|
## Binding Catalog
|
||||||
|
|
||||||
|
### Storage Bindings
|
||||||
|
|
||||||
|
| Binding | Use Case | Access Pattern |
|
||||||
|
|---------|----------|----------------|
|
||||||
|
| **KV** | Key-value cache, CDN-backed reads | `env.MY_KV.get(key)` |
|
||||||
|
| **R2** | Object storage (S3-compatible) | `env.MY_BUCKET.get(key)` |
|
||||||
|
| **D1** | SQL database (SQLite) | `env.DB.prepare(sql).all()` |
|
||||||
|
| **Durable Objects** | Coordination, real-time state | `env.MY_DO.get(id)` |
|
||||||
|
| **Vectorize** | Vector embeddings search | `env.VECTORIZE.query(vector)` |
|
||||||
|
| **Queues** | Async message processing | `env.MY_QUEUE.send(msg)` |
|
||||||
|
|
||||||
|
### Compute Bindings
|
||||||
|
|
||||||
|
| Binding | Use Case | Access Pattern |
|
||||||
|
|---------|----------|----------------|
|
||||||
|
| **Service** | Worker-to-Worker RPC | `env.MY_SERVICE.fetch(req)` |
|
||||||
|
| **Workers AI** | LLM inference | `env.AI.run(model, input)` |
|
||||||
|
| **Browser Rendering** | Headless Chrome | `env.BROWSER.fetch(url)` |
|
||||||
|
|
||||||
|
### Platform Bindings
|
||||||
|
|
||||||
|
| Binding | Use Case | Access Pattern |
|
||||||
|
|---------|----------|----------------|
|
||||||
|
| **Analytics Engine** | Custom metrics | `env.ANALYTICS.writeDataPoint(data)` |
|
||||||
|
| **mTLS** | Client certificates | `env.MY_CERT` (string) |
|
||||||
|
| **Hyperdrive** | Database pooling | `env.HYPERDRIVE.connectionString` |
|
||||||
|
| **Rate Limiting** | Request throttling | `env.RATE_LIMITER.limit(id)` |
|
||||||
|
| **Workflows** | Long-running workflows | `env.MY_WORKFLOW.create()` |
|
||||||
|
|
||||||
|
### Configuration Bindings
|
||||||
|
|
||||||
|
| Binding | Use Case | Access Pattern |
|
||||||
|
|---------|----------|----------------|
|
||||||
|
| **Environment Variables** | Non-sensitive config | `env.API_URL` (string) |
|
||||||
|
| **Secrets** | Sensitive values | `env.API_KEY` (string) |
|
||||||
|
| **Text/Data Blobs** | Static files | `env.MY_BLOB` (string) |
|
||||||
|
| **WASM** | WebAssembly modules | `env.MY_WASM` (WebAssembly.Module) |
|
||||||
|
|
||||||
|
## Quick Selection Guide
|
||||||
|
|
||||||
|
**Need persistent storage?**
|
||||||
|
- Key-value < 25MB → **KV**
|
||||||
|
- Files/objects → **R2**
|
||||||
|
- Relational data → **D1**
|
||||||
|
- Real-time coordination → **Durable Objects**
|
||||||
|
|
||||||
|
**Need AI/compute?**
|
||||||
|
- LLM inference → **Workers AI**
|
||||||
|
- Scraping/PDFs → **Browser Rendering**
|
||||||
|
- Call another Worker → **Service binding**
|
||||||
|
|
||||||
|
**Need async processing?**
|
||||||
|
- Background jobs → **Queues**
|
||||||
|
|
||||||
|
**Need config?**
|
||||||
|
- Public values → **Environment Variables**
|
||||||
|
- Secrets → **Secrets** (never commit)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. **Add binding to wrangler.jsonc:**
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"kv_namespaces": [
|
||||||
|
{ "binding": "MY_KV", "id": "your-kv-id" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Generate types:**
|
||||||
|
```bash
|
||||||
|
npx wrangler types
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Access in Worker:**
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request, env, ctx) {
|
||||||
|
await env.MY_KV.put('key', 'value');
|
||||||
|
return new Response('OK');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Safety
|
||||||
|
|
||||||
|
Bindings are fully typed via `wrangler types`. See [api.md](api.md) for details.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- 64 bindings max per Worker (all types combined)
|
||||||
|
- See [gotchas.md](gotchas.md) for per-binding limits
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
**Zero-overhead access:** Bindings compiled into Worker, no network calls to access
|
||||||
|
**Type-safe:** Full TypeScript support via `wrangler types`
|
||||||
|
**Per-environment:** Different IDs for dev/staging/production
|
||||||
|
**Secrets vs Vars:** Secrets encrypted at rest, never in config files
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Cloudflare Docs: Bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/)
|
||||||
|
- [Wrangler Configuration](https://developers.cloudflare.com/workers/wrangler/configuration/)
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Bindings API Reference
|
||||||
|
|
||||||
|
## TypeScript Types
|
||||||
|
|
||||||
|
Cloudflare generates binding types via `npx wrangler types`. This creates `.wrangler/types/runtime.d.ts` with your Env interface.
|
||||||
|
|
||||||
|
### Generated Env Interface
|
||||||
|
|
||||||
|
After running `wrangler types`, TypeScript knows your bindings:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Env {
|
||||||
|
// From wrangler.jsonc bindings
|
||||||
|
MY_KV: KVNamespace;
|
||||||
|
MY_BUCKET: R2Bucket;
|
||||||
|
DB: D1Database;
|
||||||
|
MY_SERVICE: Fetcher;
|
||||||
|
AI: Ai;
|
||||||
|
|
||||||
|
// From vars
|
||||||
|
API_URL: string;
|
||||||
|
|
||||||
|
// From secrets (set via wrangler secret put)
|
||||||
|
API_KEY: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Binding Types
|
||||||
|
|
||||||
|
| Config | TypeScript Type | Package |
|
||||||
|
|--------|-----------------|---------|
|
||||||
|
| `kv_namespaces` | `KVNamespace` | `@cloudflare/workers-types` |
|
||||||
|
| `r2_buckets` | `R2Bucket` | `@cloudflare/workers-types` |
|
||||||
|
| `d1_databases` | `D1Database` | `@cloudflare/workers-types` |
|
||||||
|
| `durable_objects.bindings` | `DurableObjectNamespace` | `@cloudflare/workers-types` |
|
||||||
|
| `vectorize` | `VectorizeIndex` | `@cloudflare/workers-types` |
|
||||||
|
| `queues.producers` | `Queue` | `@cloudflare/workers-types` |
|
||||||
|
| `services` | `Fetcher` | `@cloudflare/workers-types` |
|
||||||
|
| `ai` | `Ai` | `@cloudflare/workers-types` |
|
||||||
|
| `browser` | `Fetcher` | `@cloudflare/workers-types` |
|
||||||
|
| `analytics_engine_datasets` | `AnalyticsEngineDataset` | `@cloudflare/workers-types` |
|
||||||
|
| `hyperdrive` | `Hyperdrive` | `@cloudflare/workers-types` |
|
||||||
|
| `rate_limiting` | `RateLimit` | `@cloudflare/workers-types` |
|
||||||
|
| `workflows` | `Workflow` | `@cloudflare/workers-types` |
|
||||||
|
| `mtls_certificates` / `vars` / `text_blobs` / `data_blobs` | `string` | Built-in |
|
||||||
|
| `wasm_modules` | `WebAssembly.Module` | Built-in |
|
||||||
|
|
||||||
|
## Accessing Bindings
|
||||||
|
|
||||||
|
### Method 1: fetch() Handler (Recommended)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
const value = await env.MY_KV.get('key');
|
||||||
|
return new Response(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Type-safe, aligns with Workers API, supports ctx for waitUntil/passThroughOnException.
|
||||||
|
|
||||||
|
### Method 2: Hono Framework
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
const app = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
|
app.get('/', async (c) => {
|
||||||
|
const value = await c.env.MY_KV.get('key');
|
||||||
|
return c.json({ value });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** c.env auto-typed, ergonomic for routing-heavy apps.
|
||||||
|
|
||||||
|
### Method 3: Module Workers (Legacy)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
||||||
|
const value = await env.MY_KV.get('key');
|
||||||
|
return new Response(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener('fetch', (event) => {
|
||||||
|
// env not directly available - requires workarounds
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avoid:** Use fetch() handler instead (Method 1).
|
||||||
|
|
||||||
|
## Type Generation Workflow
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install wrangler
|
||||||
|
npm install -D wrangler
|
||||||
|
|
||||||
|
# Generate types from wrangler.jsonc
|
||||||
|
npx wrangler types
|
||||||
|
```
|
||||||
|
|
||||||
|
### After Changing Bindings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Added/modified binding in wrangler.jsonc
|
||||||
|
npx wrangler types
|
||||||
|
|
||||||
|
# TypeScript now sees updated Env interface
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** `wrangler types` outputs to `.wrangler/types/runtime.d.ts`. TypeScript picks this up automatically if `@cloudflare/workers-types` is in `tsconfig.json` `"types"` array.
|
||||||
|
|
||||||
|
## Key Binding Methods
|
||||||
|
|
||||||
|
**KV:**
|
||||||
|
```typescript
|
||||||
|
await env.MY_KV.get(key, { type: 'json' }); // text|json|arrayBuffer|stream
|
||||||
|
await env.MY_KV.put(key, value, { expirationTtl: 3600 });
|
||||||
|
await env.MY_KV.delete(key);
|
||||||
|
await env.MY_KV.list({ prefix: 'user:' });
|
||||||
|
```
|
||||||
|
|
||||||
|
**R2:**
|
||||||
|
```typescript
|
||||||
|
await env.BUCKET.get(key);
|
||||||
|
await env.BUCKET.put(key, value);
|
||||||
|
await env.BUCKET.delete(key);
|
||||||
|
await env.BUCKET.list({ prefix: 'images/' });
|
||||||
|
```
|
||||||
|
|
||||||
|
**D1:**
|
||||||
|
```typescript
|
||||||
|
await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId).first();
|
||||||
|
await env.DB.batch([stmt1, stmt2]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service:**
|
||||||
|
```typescript
|
||||||
|
await env.MY_SERVICE.fetch(new Request('https://fake/path'));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workers AI:**
|
||||||
|
```typescript
|
||||||
|
await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Queues:**
|
||||||
|
```typescript
|
||||||
|
await env.MY_QUEUE.send({ userId: 123, action: 'process' });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Durable Objects:**
|
||||||
|
```typescript
|
||||||
|
const id = env.MY_DO.idFromName('user-123');
|
||||||
|
const stub = env.MY_DO.get(id);
|
||||||
|
await stub.fetch(new Request('https://fake/increment'));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime vs Build-Time Types
|
||||||
|
|
||||||
|
| Type Source | When Generated | Use Case |
|
||||||
|
|-------------|----------------|----------|
|
||||||
|
| `@cloudflare/workers-types` | npm install | Base Workers APIs (Request, Response, etc.) |
|
||||||
|
| `wrangler types` | After config change | Your specific bindings (Env interface) |
|
||||||
|
|
||||||
|
**Install both:**
|
||||||
|
```bash
|
||||||
|
npm install -D @cloudflare/workers-types
|
||||||
|
npx wrangler types
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Safety Best Practices
|
||||||
|
|
||||||
|
1. **Never use `any` for env:**
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD
|
||||||
|
async fetch(request: Request, env: any) { }
|
||||||
|
|
||||||
|
// ✅ GOOD
|
||||||
|
async fetch(request: Request, env: Env) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run wrangler types after config changes:**
|
||||||
|
```bash
|
||||||
|
# After editing wrangler.jsonc
|
||||||
|
npx wrangler types
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check generated types match config:**
|
||||||
|
```bash
|
||||||
|
# View generated Env interface
|
||||||
|
cat .wrangler/types/runtime.d.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Workers Types Package](https://www.npmjs.com/package/@cloudflare/workers-types)
|
||||||
|
- [Wrangler Types Command](https://developers.cloudflare.com/workers/wrangler/commands/#types)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# Binding Configuration Reference
|
||||||
|
|
||||||
|
## Storage Bindings
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"kv_namespaces": [{ "binding": "MY_KV", "id": "..." }],
|
||||||
|
"r2_buckets": [{ "binding": "MY_BUCKET", "bucket_name": "my-bucket" }],
|
||||||
|
"d1_databases": [{ "binding": "DB", "database_name": "my-db", "database_id": "..." }],
|
||||||
|
"durable_objects": { "bindings": [{ "name": "MY_DO", "class_name": "MyDO" }] },
|
||||||
|
"vectorize": [{ "binding": "VECTORIZE", "index_name": "my-index" }],
|
||||||
|
"queues": { "producers": [{ "binding": "MY_QUEUE", "queue": "my-queue" }] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create commands:**
|
||||||
|
```bash
|
||||||
|
npx wrangler kv namespace create MY_KV
|
||||||
|
npx wrangler r2 bucket create my-bucket
|
||||||
|
npx wrangler d1 create my-db
|
||||||
|
npx wrangler vectorize create my-index --dimensions=768 --metric=cosine
|
||||||
|
npx wrangler queues create my-queue
|
||||||
|
|
||||||
|
# List existing resources
|
||||||
|
npx wrangler kv namespace list
|
||||||
|
npx wrangler r2 bucket list
|
||||||
|
npx wrangler d1 list
|
||||||
|
npx wrangler vectorize list
|
||||||
|
npx wrangler queues list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compute Bindings
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"services": [{
|
||||||
|
"binding": "MY_SERVICE",
|
||||||
|
"service": "other-worker",
|
||||||
|
"environment": "production" // Optional: target specific env
|
||||||
|
}],
|
||||||
|
"ai": { "binding": "AI" },
|
||||||
|
"browser": { "binding": "BROWSER" },
|
||||||
|
"workflows": [{ "binding": "MY_WORKFLOW", "name": "my-workflow" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create workflows:**
|
||||||
|
```bash
|
||||||
|
npx wrangler workflows create my-workflow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Bindings
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"analytics_engine_datasets": [{ "binding": "ANALYTICS" }],
|
||||||
|
"mtls_certificates": [{ "binding": "MY_CERT", "certificate_id": "..." }],
|
||||||
|
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "..." }],
|
||||||
|
"unsafe": {
|
||||||
|
"bindings": [{ "name": "RATE_LIMITER", "type": "ratelimit", "namespace_id": "..." }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Bindings
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"vars": {
|
||||||
|
"API_URL": "https://api.example.com",
|
||||||
|
"MAX_RETRIES": "3"
|
||||||
|
},
|
||||||
|
"text_blobs": { "MY_TEXT": "./data/template.html" },
|
||||||
|
"data_blobs": { "MY_DATA": "./data/config.bin" },
|
||||||
|
"wasm_modules": { "MY_WASM": "./build/module.wasm" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Secrets (never in config):**
|
||||||
|
```bash
|
||||||
|
npx wrangler secret put API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment-Specific Configuration
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "my-worker",
|
||||||
|
"vars": { "ENV": "production" },
|
||||||
|
"kv_namespaces": [{ "binding": "CACHE", "id": "prod-kv-id" }],
|
||||||
|
|
||||||
|
"env": {
|
||||||
|
"staging": {
|
||||||
|
"vars": { "ENV": "staging" },
|
||||||
|
"kv_namespaces": [{ "binding": "CACHE", "id": "staging-kv-id" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deploy:**
|
||||||
|
```bash
|
||||||
|
npx wrangler deploy # Production
|
||||||
|
npx wrangler deploy --env staging
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"kv_namespaces": [{
|
||||||
|
"binding": "MY_KV",
|
||||||
|
"id": "prod-id",
|
||||||
|
"preview_id": "dev-id" // Used in wrangler dev
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or use remote:**
|
||||||
|
```bash
|
||||||
|
npx wrangler dev --remote # Uses production bindings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "my-app",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2025-01-01",
|
||||||
|
|
||||||
|
"vars": { "API_URL": "https://api.example.com" },
|
||||||
|
"kv_namespaces": [{ "binding": "CACHE", "id": "abc123" }],
|
||||||
|
"r2_buckets": [{ "binding": "ASSETS", "bucket_name": "my-assets" }],
|
||||||
|
"d1_databases": [{ "binding": "DB", "database_name": "my-db", "database_id": "xyz789" }],
|
||||||
|
"services": [{ "binding": "AUTH", "service": "auth-worker" }],
|
||||||
|
"ai": { "binding": "AI" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Binding-Specific Configuration
|
||||||
|
|
||||||
|
### Durable Objects with Class Export
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{ "name": "COUNTER", "class_name": "Counter", "script_name": "my-worker" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In same Worker or script_name Worker
|
||||||
|
export class Counter {
|
||||||
|
constructor(private state: DurableObjectState, private env: Env) {}
|
||||||
|
async fetch(request: Request) { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Queue Consumers
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"queues": {
|
||||||
|
"producers": [{ "binding": "MY_QUEUE", "queue": "my-queue" }],
|
||||||
|
"consumers": [{ "queue": "my-queue", "max_batch_size": 10 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Queue consumer handler: `export default { async queue(batch, env) { /* process batch.messages */ } }`
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- **64 binding limit** (all types combined)
|
||||||
|
- **Secrets**: Always use `wrangler secret put`, never commit
|
||||||
|
- **Types**: Run `npx wrangler types` after config changes
|
||||||
|
- **Environments**: Use `env` field for staging/production variants
|
||||||
|
- **Development**: Use `preview_id` or `--remote` flag
|
||||||
|
- **IDs vs Names**: Some bindings use `id` (KV, D1), others use `name` (R2, Queues)
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Wrangler Configuration](https://developers.cloudflare.com/workers/wrangler/configuration/)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Binding Gotchas and Troubleshooting
|
||||||
|
|
||||||
|
## Critical: Global Scope Mutation
|
||||||
|
|
||||||
|
### ❌ THE #1 GOTCHA: Caching env in Global Scope
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ DANGEROUS - env cached at deploy time
|
||||||
|
const apiKey = env.API_KEY; // ERROR: env not available in global scope
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
// Uses undefined or stale value!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why it breaks:**
|
||||||
|
- `env` not available in global scope
|
||||||
|
- If using workarounds, secrets may not update without redeployment
|
||||||
|
- Leads to "Cannot read property 'X' of undefined" errors
|
||||||
|
|
||||||
|
**✅ Always access env per-request:**
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
const apiKey = env.API_KEY; // Fresh every request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### "env.MY_KV is undefined"
|
||||||
|
|
||||||
|
**Cause:** Name mismatch or not configured
|
||||||
|
**Solution:** Check wrangler.jsonc (case-sensitive), run `npx wrangler types`, verify `npx wrangler kv namespace list`
|
||||||
|
|
||||||
|
### "Property 'MY_KV' does not exist on type 'Env'"
|
||||||
|
|
||||||
|
**Cause:** Types not generated
|
||||||
|
**Solution:** `npx wrangler types`
|
||||||
|
|
||||||
|
### "preview_id is required for --remote"
|
||||||
|
|
||||||
|
**Cause:** Missing preview binding
|
||||||
|
**Solution:** Add `"preview_id": "dev-id"` or use `npx wrangler dev` (local mode)
|
||||||
|
|
||||||
|
### "Secret updated but Worker still uses old value"
|
||||||
|
|
||||||
|
**Cause:** Cached in global scope or not redeployed
|
||||||
|
**Solution:** Avoid global caching, redeploy after secret change
|
||||||
|
|
||||||
|
### "KV get() returns null for existing key"
|
||||||
|
|
||||||
|
**Cause:** Eventual consistency (60s), wrong namespace, wrong environment
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Check key exists
|
||||||
|
npx wrangler kv key get --binding=MY_KV "your-key"
|
||||||
|
|
||||||
|
# Verify namespace ID
|
||||||
|
npx wrangler kv namespace list
|
||||||
|
|
||||||
|
# Check environment
|
||||||
|
npx wrangler deployments list
|
||||||
|
```
|
||||||
|
|
||||||
|
### "D1 database not found"
|
||||||
|
|
||||||
|
**Solution:** `npx wrangler d1 list`, verify ID in wrangler.jsonc
|
||||||
|
|
||||||
|
### "Service binding returns 'No such service'"
|
||||||
|
|
||||||
|
**Cause:** Target Worker not deployed, name mismatch, environment mismatch
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# List deployed Workers
|
||||||
|
npx wrangler deployments list --name=target-worker
|
||||||
|
|
||||||
|
# Check service binding config
|
||||||
|
cat wrangler.jsonc | grep -A2 services
|
||||||
|
|
||||||
|
# Deploy target first
|
||||||
|
cd ../target-worker && npx wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Rate limit exceeded" on KV writes
|
||||||
|
|
||||||
|
**Cause:** >1 write/second per key
|
||||||
|
**Solution:** Use different keys, Durable Objects, or Queues
|
||||||
|
|
||||||
|
## Type Safety Gotchas
|
||||||
|
|
||||||
|
### Missing @cloudflare/workers-types
|
||||||
|
|
||||||
|
**Error:** `Cannot find name 'Request'`
|
||||||
|
**Solution:** `npm install -D @cloudflare/workers-types`, add to tsconfig.json `"types"`
|
||||||
|
|
||||||
|
### Binding Type Mismatches
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Wrong - KV returns string | null
|
||||||
|
const value: string = await env.MY_KV.get('key');
|
||||||
|
|
||||||
|
// ✅ Handle null
|
||||||
|
const value = await env.MY_KV.get('key');
|
||||||
|
if (!value) return new Response('Not found', { status: 404 });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Gotchas
|
||||||
|
|
||||||
|
### Wrong Environment Deployed
|
||||||
|
|
||||||
|
**Solution:** Check `npx wrangler deployments list`, use `--env` flag
|
||||||
|
|
||||||
|
### Secrets Not Per-Environment
|
||||||
|
|
||||||
|
**Solution:** Set per environment: `npx wrangler secret put API_KEY --env staging`
|
||||||
|
|
||||||
|
## Development Gotchas
|
||||||
|
|
||||||
|
**wrangler dev vs deploy:**
|
||||||
|
- dev: Uses `preview_id` or local bindings, secrets not available
|
||||||
|
- deploy: Uses production `id`, secrets available
|
||||||
|
|
||||||
|
**Access secrets in dev:** `npx wrangler dev --remote`
|
||||||
|
**Persist local data:** `npx wrangler dev --persist`
|
||||||
|
|
||||||
|
## Performance Gotchas
|
||||||
|
|
||||||
|
### Sequential Binding Calls
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Slow
|
||||||
|
const user = await env.DB.prepare('...').first();
|
||||||
|
const config = await env.MY_KV.get('config');
|
||||||
|
|
||||||
|
// ✅ Parallel
|
||||||
|
const [user, config] = await Promise.all([
|
||||||
|
env.DB.prepare('...').first(),
|
||||||
|
env.MY_KV.get('config')
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Gotchas
|
||||||
|
|
||||||
|
**❌ Secrets in logs:** `console.log('Key:', env.API_KEY)` - visible in dashboard
|
||||||
|
**✅** `console.log('Key:', env.API_KEY ? '***' : 'missing')`
|
||||||
|
|
||||||
|
**❌ Exposing env:** `return Response.json(env)` - exposes all bindings
|
||||||
|
**✅** Never return env object in responses
|
||||||
|
|
||||||
|
## Limits Reference
|
||||||
|
|
||||||
|
| Resource | Limit | Impact | Plan |
|
||||||
|
|----------|-------|--------|------|
|
||||||
|
| **Bindings per Worker** | 64 total | All binding types combined | All |
|
||||||
|
| **Environment variables** | 64 max, 5KB each | Per Worker | All |
|
||||||
|
| **Secret size** | 1KB | Per secret | All |
|
||||||
|
| **KV key size** | 512 bytes | UTF-8 encoded | All |
|
||||||
|
| **KV value size** | 25 MB | Per value | All |
|
||||||
|
| **KV writes per key** | 1/second | Per key; exceeding = 429 error | All |
|
||||||
|
| **KV list() results** | 1000 keys | Per call; use cursor for more | All |
|
||||||
|
| **KV operations** | 1000 reads/day | Free tier only | Free |
|
||||||
|
| **R2 object size** | 5 TB | Per object | All |
|
||||||
|
| **R2 operations** | 1M Class A/month free | Writes | All |
|
||||||
|
| **D1 database size** | 10 GB | Per database | All |
|
||||||
|
| **D1 rows per query** | 100,000 | Result set limit | All |
|
||||||
|
| **D1 databases** | 10 | Free tier | Free |
|
||||||
|
| **Queue batch size** | 100 messages | Per consumer batch | All |
|
||||||
|
| **Queue message size** | 128 KB | Per message | All |
|
||||||
|
| **Service binding calls** | Unlimited | Counts toward CPU time | All |
|
||||||
|
| **Durable Objects** | 1M requests/month free | First 1M | Free |
|
||||||
|
|
||||||
|
## Debugging Tips
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check configuration
|
||||||
|
npx wrangler deploy --dry-run # Validate config without deploying
|
||||||
|
npx wrangler kv namespace list # List KV namespaces
|
||||||
|
npx wrangler secret list # List secrets (not values)
|
||||||
|
npx wrangler deployments list # Recent deployments
|
||||||
|
|
||||||
|
# Inspect bindings
|
||||||
|
npx wrangler kv key list --binding=MY_KV
|
||||||
|
npx wrangler kv key get --binding=MY_KV "key-name"
|
||||||
|
npx wrangler r2 object get my-bucket/file.txt
|
||||||
|
npx wrangler d1 execute my-db --command="SELECT * FROM sqlite_master"
|
||||||
|
|
||||||
|
# Test locally
|
||||||
|
npx wrangler dev # Local mode
|
||||||
|
npx wrangler dev --remote # Production bindings
|
||||||
|
npx wrangler dev --persist # Persist data across restarts
|
||||||
|
|
||||||
|
# Verify types
|
||||||
|
npx wrangler types
|
||||||
|
cat .wrangler/types/runtime.d.ts | grep "interface Env"
|
||||||
|
|
||||||
|
# Debug specific binding issues
|
||||||
|
npx wrangler tail # Stream logs in real-time
|
||||||
|
npx wrangler tail --format=pretty # Formatted logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Workers Limits](https://developers.cloudflare.com/workers/platform/limits/)
|
||||||
|
- [Wrangler Commands](https://developers.cloudflare.com/workers/wrangler/commands/)
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Binding Patterns and Best Practices
|
||||||
|
|
||||||
|
## Service Binding Patterns
|
||||||
|
|
||||||
|
### RPC via Service Bindings
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// auth-worker
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
const token = request.headers.get('Authorization');
|
||||||
|
return new Response(JSON.stringify({ valid: await validateToken(token) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// api-worker
|
||||||
|
const response = await env.AUTH_SERVICE.fetch(
|
||||||
|
new Request('https://fake-host/validate', {
|
||||||
|
headers: { 'Authorization': token }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why RPC?** Zero latency (same datacenter), no DNS, free, type-safe.
|
||||||
|
|
||||||
|
**HTTP vs Service:**
|
||||||
|
```typescript
|
||||||
|
// ❌ HTTP (slow, paid, cross-region latency)
|
||||||
|
await fetch('https://auth-worker.example.com/validate');
|
||||||
|
|
||||||
|
// ✅ Service binding (fast, free, same isolate)
|
||||||
|
await env.AUTH_SERVICE.fetch(new Request('https://fake-host/validate'));
|
||||||
|
```
|
||||||
|
|
||||||
|
**URL doesn't matter:** Service bindings ignore hostname/protocol, routing happens via binding name.
|
||||||
|
|
||||||
|
### Typed Service RPC
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared-types.ts
|
||||||
|
export interface AuthRequest { token: string; }
|
||||||
|
export interface AuthResponse { valid: boolean; userId?: string; }
|
||||||
|
|
||||||
|
// auth-worker
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const body: AuthRequest = await request.json();
|
||||||
|
const response: AuthResponse = { valid: true, userId: '123' };
|
||||||
|
return Response.json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// api-worker
|
||||||
|
const response = await env.AUTH_SERVICE.fetch(
|
||||||
|
new Request('https://fake/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ token } satisfies AuthRequest)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const data: AuthResponse = await response.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secrets Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set secret
|
||||||
|
npx wrangler secret put API_KEY
|
||||||
|
cat api-key.txt | npx wrangler secret put API_KEY
|
||||||
|
npx wrangler secret put API_KEY --env staging
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Use secret
|
||||||
|
const response = await fetch('https://api.example.com', {
|
||||||
|
headers: { 'Authorization': `Bearer ${env.API_KEY}` }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never commit secrets:**
|
||||||
|
```jsonc
|
||||||
|
// ❌ NEVER
|
||||||
|
{ "vars": { "API_KEY": "sk_live_abc123" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing with Mock Bindings
|
||||||
|
|
||||||
|
### Vitest Mock
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
const mockKV: KVNamespace = {
|
||||||
|
get: vi.fn(async (key) => key === 'test' ? 'value' : null),
|
||||||
|
put: vi.fn(async () => {}),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
list: vi.fn(async () => ({ keys: [], list_complete: true, cursor: '' })),
|
||||||
|
getWithMetadata: vi.fn(),
|
||||||
|
} as unknown as KVNamespace;
|
||||||
|
|
||||||
|
const mockEnv: Env = { MY_KV: mockKV };
|
||||||
|
const mockCtx: ExecutionContext = {
|
||||||
|
waitUntil: vi.fn(),
|
||||||
|
passThroughOnException: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await worker.fetch(
|
||||||
|
new Request('http://localhost/test'),
|
||||||
|
mockEnv,
|
||||||
|
mockCtx
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Binding Access Patterns
|
||||||
|
|
||||||
|
### Lazy Access
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Access only when needed
|
||||||
|
if (url.pathname === '/cached') {
|
||||||
|
const cached = await env.MY_KV.get('data');
|
||||||
|
if (cached) return new Response(cached);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parallel Access
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Parallelize independent calls
|
||||||
|
const [user, config, cache] = await Promise.all([
|
||||||
|
env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId).first(),
|
||||||
|
env.MY_KV.get('config'),
|
||||||
|
env.CACHE.get('data')
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage Selection
|
||||||
|
|
||||||
|
### KV: CDN-Backed Reads
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config = await env.MY_KV.get('app-config', { type: 'json' });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use when:** Read-heavy, <25MB, global distribution, eventual consistency OK
|
||||||
|
**Latency:** <10ms reads (cached), writes eventually consistent (60s)
|
||||||
|
|
||||||
|
### D1: Relational Queries
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const results = await env.DB.prepare(`
|
||||||
|
SELECT u.name, COUNT(o.id) FROM users u
|
||||||
|
LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id
|
||||||
|
`).all();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use when:** Relational data, JOINs, ACID transactions
|
||||||
|
**Limits:** 10GB database size, 100k rows per query
|
||||||
|
|
||||||
|
### R2: Large Objects
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const object = await env.MY_BUCKET.get('large-file.zip');
|
||||||
|
return new Response(object.body);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use when:** Files >25MB, S3-compatible API needed
|
||||||
|
**Limits:** 5TB per object, unlimited storage
|
||||||
|
|
||||||
|
### Durable Objects: Coordination
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const id = env.COUNTER.idFromName('global');
|
||||||
|
const stub = env.COUNTER.get(id);
|
||||||
|
await stub.fetch(new Request('https://fake/increment'));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use when:** Strong consistency, real-time coordination, WebSocket state
|
||||||
|
**Guarantees:** Single-threaded execution, transactional storage
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
**❌ Hardcoding credentials:** `const apiKey = 'sk_live_abc123'`
|
||||||
|
**✅** `npx wrangler secret put API_KEY`
|
||||||
|
|
||||||
|
**❌ Using REST API:** `fetch('https://api.cloudflare.com/.../kv/...')`
|
||||||
|
**✅** `env.MY_KV.get('key')`
|
||||||
|
|
||||||
|
**❌ Polling storage:** `setInterval(() => env.KV.get('config'), 1000)`
|
||||||
|
**✅** Use Durable Objects for real-time state
|
||||||
|
|
||||||
|
**❌ Large data in vars:** `{ "vars": { "HUGE_CONFIG": "..." } }` (5KB max)
|
||||||
|
**✅** `env.MY_KV.put('config', data)`
|
||||||
|
|
||||||
|
**❌ Caching env globally:** `const apiKey = env.API_KEY` outside fetch()
|
||||||
|
**✅** Access `env.API_KEY` per-request inside fetch()
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Service Bindings Docs](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/)
|
||||||
|
- [Miniflare Testing](https://miniflare.dev/)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Cloudflare Bot Management
|
||||||
|
|
||||||
|
Enterprise-grade bot detection, protection, and mitigation using ML/heuristics, bot scores, JavaScript detections, and verified bot handling.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bot Management provides multi-tier protection:
|
||||||
|
- **Free (Bot Fight Mode)**: Auto-blocks definite bots, no config
|
||||||
|
- **Pro/Business (Super Bot Fight Mode)**: Configurable actions, static resource protection, analytics groupings
|
||||||
|
- **Enterprise (Bot Management)**: Granular 1-99 scores, WAF integration, JA3/JA4 fingerprinting, Workers API, Advanced Analytics
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# Dashboard: Security > Bots
|
||||||
|
# Enterprise: Deploy rule template
|
||||||
|
(cf.bot_management.score eq 1 and not cf.bot_management.verified_bot) → Block
|
||||||
|
(cf.bot_management.score le 29 and not cf.bot_management.verified_bot) → Managed Challenge
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Do You Need?
|
||||||
|
|
||||||
|
```txt
|
||||||
|
├─ Initial setup → configuration.md
|
||||||
|
│ ├─ Free tier → "Bot Fight Mode"
|
||||||
|
│ ├─ Pro/Business → "Super Bot Fight Mode"
|
||||||
|
│ └─ Enterprise → "Bot Management for Enterprise"
|
||||||
|
├─ Workers API integration → api.md
|
||||||
|
├─ WAF rules → patterns.md
|
||||||
|
├─ Debugging → gotchas.md
|
||||||
|
└─ Analytics → api.md#bot-analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
| Task | Files to Read |
|
||||||
|
|------|---------------|
|
||||||
|
| Enable bot protection | README → configuration.md |
|
||||||
|
| Workers bot detection | README → api.md |
|
||||||
|
| WAF rule templates | README → patterns.md |
|
||||||
|
| Debug bot issues | gotchas.md |
|
||||||
|
| Advanced analytics | api.md#bot-analytics |
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
**Bot Scores**: 1-99 (1 = definitely automated, 99 = definitely human). Threshold: <30 indicates bot traffic. Enterprise gets granular 1-99; Pro/Business get groupings only.
|
||||||
|
|
||||||
|
**Detection Engines**: Heuristics (known fingerprints, assigns score=1), ML (majority of detections, supervised learning on billions of requests), Anomaly Detection (optional, baseline traffic analysis), JavaScript Detections (headless browser detection).
|
||||||
|
|
||||||
|
**Verified Bots**: Allowlisted good bots (search engines, AI crawlers) verified via reverse DNS or Web Bot Auth. Access via `cf.bot_management.verified_bot` or `cf.verified_bot_category`.
|
||||||
|
|
||||||
|
## Platform Limits
|
||||||
|
|
||||||
|
| Plan | Bot Scores | JA3/JA4 | Custom Rules | Analytics Retention |
|
||||||
|
|------|------------|---------|--------------|---------------------|
|
||||||
|
| Free | No (auto-block only) | No | 5 | N/A (no analytics) |
|
||||||
|
| Pro/Business | Groupings only | No | 20/100 | 30 days (72h at a time) |
|
||||||
|
| Enterprise | 1-99 granular | Yes | 1,000+ | 30 days (1 week at a time) |
|
||||||
|
|
||||||
|
## Basic Patterns
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Workers: Check bot score
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const botScore = request.cf?.botManagement?.score;
|
||||||
|
if (botScore && botScore < 30 && !request.cf?.botManagement?.verifiedBot) {
|
||||||
|
return new Response('Bot detected', { status: 403 });
|
||||||
|
}
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# WAF: Block definite bots
|
||||||
|
(cf.bot_management.score eq 1 and not cf.bot_management.verified_bot)
|
||||||
|
|
||||||
|
# WAF: Protect sensitive endpoints
|
||||||
|
(cf.bot_management.score lt 50 and http.request.uri.path in {"/login" "/checkout"} and not cf.bot_management.verified_bot)
|
||||||
|
```
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
- [configuration.md](./configuration.md) - Product tiers, WAF rule setup, JavaScript Detections, ML auto-updates
|
||||||
|
- [api.md](./api.md) - Workers BotManagement interface, WAF fields, JA4 Signals
|
||||||
|
- [patterns.md](./patterns.md) - E-commerce, API protection, mobile app allowlisting, SEO-friendly handling
|
||||||
|
- [gotchas.md](./gotchas.md) - False positives/negatives, score=0 issues, JSD limitations, CSP requirements
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [waf](../waf/) - WAF custom rules for bot enforcement
|
||||||
|
- [workers](../workers/) - Workers request.cf.botManagement API
|
||||||
|
- [api-shield](../api-shield/) - API-specific bot protection
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Bot Management API
|
||||||
|
|
||||||
|
## Workers: BotManagement Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BotManagement {
|
||||||
|
score: number; // 1-99 (Enterprise), 0 if not computed
|
||||||
|
verifiedBot: boolean; // Is verified bot
|
||||||
|
staticResource: boolean; // Serves static resource
|
||||||
|
ja3Hash: string; // JA3 fingerprint (Enterprise, HTTPS only)
|
||||||
|
ja4: string; // JA4 fingerprint (Enterprise, HTTPS only)
|
||||||
|
jsDetection?: {
|
||||||
|
passed: boolean; // Passed JS detection (if enabled)
|
||||||
|
};
|
||||||
|
detectionIds: number[]; // Heuristic detection IDs
|
||||||
|
corporateProxy?: boolean; // From corporate proxy (Enterprise)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEPRECATED: Use botManagement.score instead
|
||||||
|
// request.cf.clientTrustScore (legacy, duplicate of botManagement.score)
|
||||||
|
|
||||||
|
// Access via request.cf
|
||||||
|
import type { IncomingRequestCfProperties } from '@cloudflare/workers-types';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const cf = request.cf as IncomingRequestCfProperties | undefined;
|
||||||
|
const botMgmt = cf?.botManagement;
|
||||||
|
|
||||||
|
if (!botMgmt) return fetch(request);
|
||||||
|
if (botMgmt.verifiedBot) return fetch(request); // Allow verified bots
|
||||||
|
if (botMgmt.score === 1) return new Response('Blocked', { status: 403 });
|
||||||
|
if (botMgmt.score < 30) return new Response('Challenge required', { status: 429 });
|
||||||
|
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## WAF Fields Reference
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# Score fields
|
||||||
|
cf.bot_management.score # 0-99 (0 = not computed)
|
||||||
|
cf.bot_management.verified_bot # boolean
|
||||||
|
cf.bot_management.static_resource # boolean
|
||||||
|
cf.bot_management.ja3_hash # string (Enterprise)
|
||||||
|
cf.bot_management.ja4 # string (Enterprise)
|
||||||
|
cf.bot_management.detection_ids # array
|
||||||
|
cf.bot_management.js_detection.passed # boolean
|
||||||
|
cf.bot_management.corporate_proxy # boolean (Enterprise)
|
||||||
|
cf.verified_bot_category # string
|
||||||
|
|
||||||
|
# Workers equivalent
|
||||||
|
request.cf.botManagement.score
|
||||||
|
request.cf.botManagement.verifiedBot
|
||||||
|
request.cf.botManagement.ja3Hash
|
||||||
|
request.cf.botManagement.ja4
|
||||||
|
request.cf.botManagement.jsDetection.passed
|
||||||
|
request.cf.verifiedBotCategory
|
||||||
|
```
|
||||||
|
|
||||||
|
## JA4 Signals (Enterprise)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { IncomingRequestCfProperties } from '@cloudflare/workers-types';
|
||||||
|
|
||||||
|
interface JA4Signals {
|
||||||
|
// Ratios (0.0-1.0)
|
||||||
|
heuristic_ratio_1h?: number; // Fraction flagged by heuristics
|
||||||
|
browser_ratio_1h?: number; // Fraction from real browsers
|
||||||
|
cache_ratio_1h?: number; // Fraction hitting cache
|
||||||
|
h2h3_ratio_1h?: number; // Fraction using HTTP/2 or HTTP/3
|
||||||
|
// Ranks (relative position in distribution)
|
||||||
|
uas_rank_1h?: number; // User-Agent diversity rank
|
||||||
|
paths_rank_1h?: number; // Path diversity rank
|
||||||
|
reqs_rank_1h?: number; // Request volume rank
|
||||||
|
ips_rank_1h?: number; // IP diversity rank
|
||||||
|
// Quantiles (0.0-1.0, percentile in distribution)
|
||||||
|
reqs_quantile_1h?: number; // Request volume quantile
|
||||||
|
ips_quantile_1h?: number; // IP count quantile
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const cf = request.cf as IncomingRequestCfProperties | undefined;
|
||||||
|
const ja4Signals = cf?.ja4Signals as JA4Signals | undefined;
|
||||||
|
|
||||||
|
if (!ja4Signals) return fetch(request); // Not available for HTTP or Worker routing
|
||||||
|
|
||||||
|
// Check for anomalous behavior
|
||||||
|
// High heuristic_ratio or low browser_ratio = suspicious
|
||||||
|
const heuristicRatio = ja4Signals.heuristic_ratio_1h ?? 0;
|
||||||
|
const browserRatio = ja4Signals.browser_ratio_1h ?? 0;
|
||||||
|
|
||||||
|
if (heuristicRatio > 0.5 || browserRatio < 0.3) {
|
||||||
|
return new Response('Suspicious traffic', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
See [patterns.md](./patterns.md) for Workers examples: mobile app allowlisting, corporate proxy exemption, datacenter detection, conditional delay, and more.
|
||||||
|
|
||||||
|
## Bot Analytics
|
||||||
|
|
||||||
|
### Access Locations
|
||||||
|
- Dashboard: Security > Bots (old) or Security > Analytics > Bot analysis (new)
|
||||||
|
- GraphQL API for programmatic access
|
||||||
|
- Security Events & Security Analytics
|
||||||
|
- Logpush/Logpull
|
||||||
|
|
||||||
|
### Available Data
|
||||||
|
- **Enterprise BM**: Bot scores (1-99), bot score source, distribution
|
||||||
|
- **Pro/Business**: Bot groupings (automated, likely automated, likely human)
|
||||||
|
- Top attributes: IPs, paths, user agents, countries
|
||||||
|
- Detection sources: Heuristics, ML, AD, JSD
|
||||||
|
- Verified bot categories
|
||||||
|
|
||||||
|
### Time Ranges
|
||||||
|
- **Enterprise BM**: Up to 1 week at a time, 30 days history
|
||||||
|
- **Pro/Business**: Up to 72 hours at a time, 30 days history
|
||||||
|
- Real-time in most cases, adaptive sampling (1-10% depending on volume)
|
||||||
|
|
||||||
|
## Logpush Fields
|
||||||
|
|
||||||
|
```txt
|
||||||
|
BotScore # 1-99 or 0 if not computed
|
||||||
|
BotScoreSrc # Detection engine (ML, Heuristics, etc.)
|
||||||
|
BotTags # Classification tags
|
||||||
|
BotDetectionIDs # Heuristic detection IDs
|
||||||
|
```
|
||||||
|
|
||||||
|
**BotScoreSrc values:**
|
||||||
|
- `"Heuristics"` - Known fingerprint
|
||||||
|
- `"Machine Learning"` - ML model
|
||||||
|
- `"Anomaly Detection"` - Baseline anomaly
|
||||||
|
- `"JS Detection"` - JavaScript check
|
||||||
|
- `"Cloudflare Service"` - Zero Trust
|
||||||
|
- `"Not Computed"` - Score = 0
|
||||||
|
|
||||||
|
Access via Logpush (stream to cloud storage/SIEM), Logpull (API to fetch logs), or GraphQL API (query analytics data).
|
||||||
|
|
||||||
|
## Testing with Miniflare
|
||||||
|
|
||||||
|
Miniflare provides mock botManagement data for local development:
|
||||||
|
|
||||||
|
**Default values:**
|
||||||
|
- `score: 99` (human)
|
||||||
|
- `verifiedBot: false`
|
||||||
|
- `corporateProxy: false`
|
||||||
|
- `ja3Hash: "25b4882c2bcb50cd6b469ff28c596742"`
|
||||||
|
- `staticResource: false`
|
||||||
|
- `detectionIds: []`
|
||||||
|
|
||||||
|
**Override in tests:**
|
||||||
|
```typescript
|
||||||
|
import { getPlatformProxy } from 'wrangler';
|
||||||
|
|
||||||
|
const { cf, dispose } = await getPlatformProxy();
|
||||||
|
// cf.botManagement is frozen mock object
|
||||||
|
expect(cf.botManagement.score).toBe(99);
|
||||||
|
```
|
||||||
|
|
||||||
|
For custom test data, mock request.cf in your test setup.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Bot Management Configuration
|
||||||
|
|
||||||
|
## Product Tiers
|
||||||
|
|
||||||
|
**Note:** Dashboard paths differ between old and new UI:
|
||||||
|
- **New:** Security > Settings > Filter "Bot traffic"
|
||||||
|
- **Old:** Security > Bots
|
||||||
|
|
||||||
|
Both UIs access same settings.
|
||||||
|
|
||||||
|
### Bot Score Groupings (Pro/Business)
|
||||||
|
|
||||||
|
Pro/Business users see bot score groupings instead of granular 1-99 scores:
|
||||||
|
|
||||||
|
| Score | Grouping | Meaning |
|
||||||
|
|-------|----------|---------|
|
||||||
|
| 0 | Not computed | Bot Management didn't run |
|
||||||
|
| 1 | Automated | Definite bot (heuristic match) |
|
||||||
|
| 2-29 | Likely automated | Probably bot (ML detection) |
|
||||||
|
| 30-99 | Likely human | Probably human |
|
||||||
|
| N/A | Verified bot | Allowlisted good bot |
|
||||||
|
|
||||||
|
Enterprise plans get granular 1-99 scores for custom thresholds.
|
||||||
|
|
||||||
|
### Bot Fight Mode (Free)
|
||||||
|
- Auto-blocks definite bots (score=1), excludes verified bots by default
|
||||||
|
- JavaScript Detections always enabled, no configuration options
|
||||||
|
|
||||||
|
### Super Bot Fight Mode (Pro/Business)
|
||||||
|
```txt
|
||||||
|
Dashboard: Security > Bots > Configure
|
||||||
|
- Definitely automated: Block/Challenge
|
||||||
|
- Likely automated: Challenge/Allow
|
||||||
|
- Verified bots: Allow (recommended)
|
||||||
|
- Static resource protection: ON (may block mail clients)
|
||||||
|
- JavaScript Detections: Optional
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bot Management for Enterprise
|
||||||
|
```txt
|
||||||
|
Dashboard: Security > Bots > Configure > Auto-updates: ON (recommended)
|
||||||
|
|
||||||
|
# Template 1: Block definite bots
|
||||||
|
(cf.bot_management.score eq 1 and not cf.bot_management.verified_bot and not cf.bot_management.static_resource)
|
||||||
|
Action: Block
|
||||||
|
|
||||||
|
# Template 2: Challenge likely bots
|
||||||
|
(cf.bot_management.score ge 2 and cf.bot_management.score le 29 and not cf.bot_management.verified_bot and not cf.bot_management.static_resource)
|
||||||
|
Action: Managed Challenge
|
||||||
|
```
|
||||||
|
|
||||||
|
## JavaScript Detections Setup
|
||||||
|
|
||||||
|
### Enable via Dashboard
|
||||||
|
```txt
|
||||||
|
Security > Bots > Configure Bot Management > JS Detections: ON
|
||||||
|
|
||||||
|
Update CSP: script-src 'self' /cdn-cgi/challenge-platform/;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual JS Injection (API)
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
function jsdOnload() {
|
||||||
|
window.cloudflare.jsd.executeOnce({ callback: function(result) { console.log('JSD:', result); } });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="/cdn-cgi/challenge-platform/scripts/jsd/api.js?onload=jsdOnload" async></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use API for**: Selective deployment on specific pages
|
||||||
|
**Don't combine**: Zone-wide toggle + manual injection
|
||||||
|
|
||||||
|
### WAF Rules for JSD
|
||||||
|
```txt
|
||||||
|
# NEVER use on first page visit (needs HTML page first)
|
||||||
|
(not cf.bot_management.js_detection.passed and http.request.uri.path eq "/api/user/create" and http.request.method eq "POST" and not cf.bot_management.verified_bot)
|
||||||
|
Action: Managed Challenge (always use Managed Challenge, not Block)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- First request won't have JSD data (needs HTML page first)
|
||||||
|
- Strips ETags from HTML responses
|
||||||
|
- Not supported with CSP via `<meta>` tags
|
||||||
|
- Websocket endpoints not supported
|
||||||
|
- Native mobile apps won't pass
|
||||||
|
- cf_clearance cookie: 15-minute lifespan, max 4096 bytes
|
||||||
|
|
||||||
|
## __cf_bm Cookie
|
||||||
|
|
||||||
|
Cloudflare sets `__cf_bm` cookie to smooth bot scores across user sessions:
|
||||||
|
|
||||||
|
- **Purpose:** Reduces false positives from score volatility
|
||||||
|
- **Scope:** Per-domain, HTTP-only
|
||||||
|
- **Lifespan:** Session duration
|
||||||
|
- **Privacy:** No PII—only session classification
|
||||||
|
- **Automatic:** No configuration required
|
||||||
|
|
||||||
|
Bot scores for repeat visitors consider session history via this cookie.
|
||||||
|
|
||||||
|
## Static Resource Protection
|
||||||
|
|
||||||
|
**File Extensions**: ico, jpg, png, jpeg, gif, css, js, tif, tiff, bmp, pict, webp, svg, svgz, class, jar, txt, csv, doc, docx, xls, xlsx, pdf, ps, pls, ppt, pptx, ttf, otf, woff, woff2, eot, eps, ejs, swf, torrent, midi, mid, m3u8, m4a, mp3, ogg, ts
|
||||||
|
**Plus**: `/.well-known/` path (all files)
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# Exclude static resources from bot rules
|
||||||
|
(cf.bot_management.score lt 30 and not cf.bot_management.static_resource)
|
||||||
|
```
|
||||||
|
|
||||||
|
**WARNING**: May block mail clients fetching static images
|
||||||
|
|
||||||
|
## JA3/JA4 Fingerprinting (Enterprise)
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# Block specific attack fingerprint
|
||||||
|
(cf.bot_management.ja3_hash eq "8b8e3d5e3e8b3d5e")
|
||||||
|
|
||||||
|
# Allow mobile app by fingerprint
|
||||||
|
(cf.bot_management.ja4 eq "your_mobile_app_fingerprint")
|
||||||
|
```
|
||||||
|
|
||||||
|
Only available for HTTPS/TLS traffic. Missing for Worker-routed traffic or HTTP requests.
|
||||||
|
|
||||||
|
## Verified Bot Categories
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# Allow search engines only
|
||||||
|
(cf.verified_bot_category eq "Search Engine Crawler")
|
||||||
|
|
||||||
|
# Block AI crawlers
|
||||||
|
(cf.verified_bot_category eq "AI Crawler")
|
||||||
|
Action: Block
|
||||||
|
|
||||||
|
# Or use dashboard: Security > Settings > Bot Management > Block AI Bots
|
||||||
|
```
|
||||||
|
|
||||||
|
| Category | String Value | Example |
|
||||||
|
|----------|--------------|---------|
|
||||||
|
| AI Crawler | `AI Crawler` | GPTBot, Claude-Web |
|
||||||
|
| AI Assistant | `AI Assistant` | Perplexity-User, DuckAssistBot |
|
||||||
|
| AI Search | `AI Search` | OAI-SearchBot |
|
||||||
|
| Accessibility | `Accessibility` | Accessible Web Bot |
|
||||||
|
| Academic Research | `Academic Research` | Library of Congress |
|
||||||
|
| Advertising & Marketing | `Advertising & Marketing` | Google Adsbot |
|
||||||
|
| Aggregator | `Aggregator` | Pinterest, Indeed |
|
||||||
|
| Archiver | `Archiver` | Internet Archive, CommonCrawl |
|
||||||
|
| Feed Fetcher | `Feed Fetcher` | RSS/Podcast updaters |
|
||||||
|
| Monitoring & Analytics | `Monitoring & Analytics` | Uptime monitors |
|
||||||
|
| Page Preview | `Page Preview` | Facebook/Slack link preview |
|
||||||
|
| SEO | `Search Engine Optimization` | Google Lighthouse |
|
||||||
|
| Security | `Security` | Vulnerability scanners |
|
||||||
|
| Social Media Marketing | `Social Media Marketing` | Brandwatch |
|
||||||
|
| Webhooks | `Webhooks` | Payment processors |
|
||||||
|
| Other | `Other` | Uncategorized bots |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- **ML Auto-Updates**: Enable on Enterprise for latest models
|
||||||
|
- **Start with Managed Challenge**: Test before blocking
|
||||||
|
- **Always exclude verified bots**: Use `not cf.bot_management.verified_bot`
|
||||||
|
- **Exempt corporate proxies**: For B2B traffic via `cf.bot_management.corporate_proxy`
|
||||||
|
- **Use static resource exception**: Improves performance, reduces overhead
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Bot Management Gotchas
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### "Bot Score = 0"
|
||||||
|
|
||||||
|
**Cause:** Bot Management didn't run (internal Cloudflare request, Worker routing to zone (Orange-to-Orange), or request handled before BM (Redirect Rules, etc.))
|
||||||
|
**Solution:** Check request flow and ensure Bot Management runs in request lifecycle
|
||||||
|
|
||||||
|
### "JavaScript Detections Not Working"
|
||||||
|
|
||||||
|
**Cause:** `js_detection.passed` always false or undefined due to: CSP headers don't allow `/cdn-cgi/challenge-platform/`, using on first page visit (needs HTML page first), ad blockers or disabled JS, JSD not enabled in dashboard, or using Block action (must use Managed Challenge)
|
||||||
|
**Solution:** Add CSP header `Content-Security-Policy: script-src 'self' /cdn-cgi/challenge-platform/;` and ensure JSD is enabled with Managed Challenge action
|
||||||
|
|
||||||
|
### "False Positives (Legitimate Users Blocked)"
|
||||||
|
|
||||||
|
**Cause:** Bot detection incorrectly flagging legitimate users
|
||||||
|
**Solution:** Check Bot Analytics for affected IPs/paths, identify detection source (ML, Heuristics, etc.), create exception rule like `(cf.bot_management.score lt 30 and http.request.uri.path eq "/problematic-path")` with Action: Skip (Bot Management), or allowlist by IP/ASN/country
|
||||||
|
|
||||||
|
### "False Negatives (Bots Not Caught)"
|
||||||
|
|
||||||
|
**Cause:** Bots bypassing detection
|
||||||
|
**Solution:** Lower score threshold (30 → 50), enable JavaScript Detections, add JA3/JA4 fingerprinting rules, or use rate limiting as fallback
|
||||||
|
|
||||||
|
### "Verified Bot Blocked"
|
||||||
|
|
||||||
|
**Cause:** Search engine bot blocked by WAF Managed Rules (not just Bot Management)
|
||||||
|
**Solution:** Create WAF exception for specific rule ID and verify bot via reverse DNS
|
||||||
|
|
||||||
|
### "Yandex Bot Blocked During IP Update"
|
||||||
|
|
||||||
|
**Cause:** Yandex updates bot IPs; new IPs unrecognized for 48h during propagation
|
||||||
|
**Solution:**
|
||||||
|
1. Check Security Events for specific WAF rule ID blocking Yandex
|
||||||
|
2. Create WAF exception:
|
||||||
|
```txt
|
||||||
|
(http.user_agent contains "YandexBot" and ip.src in {<yandex-ip-range>})
|
||||||
|
Action: Skip (WAF Managed Ruleset)
|
||||||
|
```
|
||||||
|
3. Monitor Bot Analytics for 48h
|
||||||
|
4. Remove exception after propagation completes
|
||||||
|
|
||||||
|
Issue resolves automatically after 48h. Contact Cloudflare Support if persists.
|
||||||
|
|
||||||
|
### "JA3/JA4 Missing"
|
||||||
|
|
||||||
|
**Cause:** Non-HTTPS traffic, Worker routing traffic, Orange-to-Orange traffic via Worker, or Bot Management skipped
|
||||||
|
**Solution:** JA3/JA4 only available for HTTPS/TLS traffic; check request routing
|
||||||
|
|
||||||
|
**JA3/JA4 Not User-Unique:** Same browser/library version = same fingerprint
|
||||||
|
- Don't use for user identification
|
||||||
|
- Use for client profiling only
|
||||||
|
- Fingerprints change with browser updates
|
||||||
|
|
||||||
|
## Bot Verification Methods
|
||||||
|
|
||||||
|
Cloudflare verifies bots via:
|
||||||
|
|
||||||
|
1. **Reverse DNS (IP validation):** Traditional method—bot IP resolves to expected domain
|
||||||
|
2. **Web Bot Auth:** Modern cryptographic verification—faster propagation
|
||||||
|
|
||||||
|
When `verifiedBot=true`, bot passed at least one method.
|
||||||
|
|
||||||
|
**Inactive verified bots:** IPs removed after 24h of no traffic.
|
||||||
|
|
||||||
|
## Detection Engine Behavior
|
||||||
|
|
||||||
|
| Engine | Score | Timing | Plan | Notes |
|
||||||
|
|--------|-------|--------|------|-------|
|
||||||
|
| Heuristics | Always 1 | Immediate | All | Known fingerprints—overrides ML |
|
||||||
|
| ML | 1-99 | Immediate | All | Majority of detections |
|
||||||
|
| Anomaly Detection | Influences | After baseline | Enterprise | Optional, baseline analysis |
|
||||||
|
| JavaScript Detections | Pass/fail | After JS | Pro+ | Headless browser detection |
|
||||||
|
| Cloudflare Service | N/A | N/A | Enterprise | Zero Trust internal source |
|
||||||
|
|
||||||
|
**Priority:** Heuristics > ML—if heuristic matches, score=1 regardless of ML.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
| Limit | Value | Notes |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| Bot Score = 0 | Means not computed | Not score = 100 |
|
||||||
|
| First request JSD data | May not be available | JSD data appears on subsequent requests |
|
||||||
|
| Score accuracy | Not 100% guaranteed | False positives/negatives possible |
|
||||||
|
| JSD on first HTML page visit | Not supported | Requires subsequent page load |
|
||||||
|
| JSD requirements | JavaScript-enabled browser | Won't work with JS disabled or ad blockers |
|
||||||
|
| JSD ETag stripping | Strips ETags from HTML responses | May affect caching behavior |
|
||||||
|
| JSD CSP compatibility | Requires specific CSP | Not compatible with some CSP configurations |
|
||||||
|
| JSD meta CSP tags | Not supported | Must use HTTP headers |
|
||||||
|
| JSD WebSocket support | Not supported | WebSocket endpoints won't work with JSD |
|
||||||
|
| JSD mobile app support | Native apps won't pass | Only works in browsers |
|
||||||
|
| JA3/JA4 traffic type | HTTPS/TLS only | Not available for non-HTTPS traffic |
|
||||||
|
| JA3/JA4 Worker routing | Missing for Worker-routed traffic | Check request routing |
|
||||||
|
| JA3/JA4 uniqueness | Not unique per user | Shared by clients with same browser/library |
|
||||||
|
| JA3/JA4 stability | Can change with updates | Browser/library updates affect fingerprints |
|
||||||
|
| WAF custom rules (Free) | 5 | Varies by plan |
|
||||||
|
| WAF custom rules (Pro) | 20 | Varies by plan |
|
||||||
|
| WAF custom rules (Business) | 100 | Varies by plan |
|
||||||
|
| WAF custom rules (Enterprise) | 1,000+ | Varies by plan |
|
||||||
|
| Workers CPU time | Varies by plan | Applies to bot logic |
|
||||||
|
| Bot Analytics sampling | 1-10% adaptive | High-volume zones sampled more aggressively |
|
||||||
|
| Bot Analytics history | 30 days max | Historical data retention limit |
|
||||||
|
| CSP requirements for JSD | Must allow `/cdn-cgi/challenge-platform/` | Required for JSD to function |
|
||||||
|
|
||||||
|
### Plan Restrictions
|
||||||
|
|
||||||
|
| Feature | Free | Pro/Business | Enterprise |
|
||||||
|
|---------|------|--------------|------------|
|
||||||
|
| Granular scores (1-99) | No | No | Yes |
|
||||||
|
| JA3/JA4 | No | No | Yes |
|
||||||
|
| Anomaly Detection | No | No | Yes |
|
||||||
|
| Corporate Proxy detection | No | No | Yes |
|
||||||
|
| Verified bot categories | Limited | Limited | Full |
|
||||||
|
| Custom WAF rules | 5 | 20/100 | 1,000+ |
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Bot Management Patterns
|
||||||
|
|
||||||
|
## E-commerce Protection
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# High security for checkout
|
||||||
|
(cf.bot_management.score lt 50 and http.request.uri.path in {"/checkout" "/cart/add"} and not cf.bot_management.verified_bot and not cf.bot_management.corporate_proxy)
|
||||||
|
Action: Managed Challenge
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Protection
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# Protect API with JS detection + score
|
||||||
|
(http.request.uri.path matches "^/api/" and (cf.bot_management.score lt 30 or not cf.bot_management.js_detection.passed) and not cf.bot_management.verified_bot)
|
||||||
|
Action: Block
|
||||||
|
```
|
||||||
|
|
||||||
|
## SEO-Friendly Bot Handling
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# Allow search engine crawlers
|
||||||
|
(cf.bot_management.score lt 30 and not cf.verified_bot_category in {"Search Engine Crawler"})
|
||||||
|
Action: Managed Challenge
|
||||||
|
```
|
||||||
|
|
||||||
|
## Block AI Scrapers
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# Block training crawlers only (allow AI assistants/search)
|
||||||
|
(cf.verified_bot_category eq "AI Crawler")
|
||||||
|
Action: Block
|
||||||
|
|
||||||
|
# Block all AI-related bots (training + assistants + search)
|
||||||
|
(cf.verified_bot_category in {"AI Crawler" "AI Assistant" "AI Search"})
|
||||||
|
Action: Block
|
||||||
|
|
||||||
|
# Allow AI Search, block AI Crawler and AI Assistant
|
||||||
|
(cf.verified_bot_category in {"AI Crawler" "AI Assistant"})
|
||||||
|
Action: Block
|
||||||
|
|
||||||
|
# Or use dashboard: Security > Settings > Bot Management > Block AI Bots
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting by Bot Score
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# Stricter limits for suspicious traffic
|
||||||
|
(cf.bot_management.score lt 50)
|
||||||
|
Rate: 10 requests per 10 seconds
|
||||||
|
|
||||||
|
(cf.bot_management.score ge 50)
|
||||||
|
Rate: 100 requests per 10 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mobile App Allowlisting
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# Identify mobile app by JA3/JA4
|
||||||
|
(cf.bot_management.ja4 in {"fingerprint1" "fingerprint2"})
|
||||||
|
Action: Skip (all remaining rules)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datacenter Detection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { IncomingRequestCfProperties } from '@cloudflare/workers-types';
|
||||||
|
|
||||||
|
// Low score + not corporate proxy = likely datacenter bot
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const cf = request.cf as IncomingRequestCfProperties | undefined;
|
||||||
|
const botMgmt = cf?.botManagement;
|
||||||
|
|
||||||
|
if (botMgmt?.score && botMgmt.score < 30 &&
|
||||||
|
!botMgmt.corporateProxy && !botMgmt.verifiedBot) {
|
||||||
|
return new Response('Datacenter traffic blocked', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Delay (Tarpit)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { IncomingRequestCfProperties } from '@cloudflare/workers-types';
|
||||||
|
|
||||||
|
// Add delay proportional to bot suspicion
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const cf = request.cf as IncomingRequestCfProperties | undefined;
|
||||||
|
const botMgmt = cf?.botManagement;
|
||||||
|
|
||||||
|
if (botMgmt?.score && botMgmt.score < 50 && !botMgmt.verifiedBot) {
|
||||||
|
// Delay: 0-2 seconds for scores 50-0
|
||||||
|
const delayMs = Math.max(0, (50 - botMgmt.score) * 40);
|
||||||
|
await new Promise(r => setTimeout(r, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layered Defense
|
||||||
|
|
||||||
|
```txt
|
||||||
|
1. Bot Management (score-based)
|
||||||
|
2. JavaScript Detections (for JS-capable clients)
|
||||||
|
3. Rate Limiting (fallback protection)
|
||||||
|
4. WAF Managed Rules (OWASP, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Progressive Enhancement
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Public content: High threshold (score < 10)
|
||||||
|
Authenticated: Medium threshold (score < 30)
|
||||||
|
Sensitive: Low threshold (score < 50) + JSD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zero Trust for Bots
|
||||||
|
|
||||||
|
```txt
|
||||||
|
1. Default deny (all scores < 30)
|
||||||
|
2. Allowlist verified bots
|
||||||
|
3. Allowlist mobile apps (JA3/JA4)
|
||||||
|
4. Allowlist corporate proxies
|
||||||
|
5. Allowlist static resources
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workers: Score + JS Detection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { IncomingRequestCfProperties } from '@cloudflare/workers-types';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const cf = request.cf as IncomingRequestCfProperties | undefined;
|
||||||
|
const botMgmt = cf?.botManagement;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (botMgmt?.staticResource) return fetch(request); // Skip static
|
||||||
|
|
||||||
|
// API endpoints: require JS detection + good score
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
const jsDetectionPassed = botMgmt?.jsDetection?.passed ?? false;
|
||||||
|
const score = botMgmt?.score ?? 100;
|
||||||
|
|
||||||
|
if (!jsDetectionPassed || score < 30) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting by JWT Claim + Bot Score
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# Enterprise: Combine bot score with JWT validation
|
||||||
|
Rate limiting > Custom rules
|
||||||
|
- Field: lookup_json_string(http.request.jwt.claims["{config_id}"][0], "sub")
|
||||||
|
- Matches: user ID claim
|
||||||
|
- Additional condition: cf.bot_management.score lt 50
|
||||||
|
```
|
||||||
|
|
||||||
|
## WAF Integration Points
|
||||||
|
|
||||||
|
- **WAF Custom Rules**: Primary enforcement mechanism
|
||||||
|
- **Rate Limiting Rules**: Bot score as dimension, stricter limits for low scores
|
||||||
|
- **Transform Rules**: Pass score to origin via custom header
|
||||||
|
- **Workers**: Programmatic bot logic, custom scoring algorithms
|
||||||
|
- **Page Rules / Configuration Rules**: Zone-level overrides, path-specific settings
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [gotchas.md](./gotchas.md) - Common errors, false positives/negatives, limitations
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Cloudflare Browser Rendering Skill Reference
|
||||||
|
|
||||||
|
**Description**: Expert knowledge for Cloudflare Browser Rendering - control headless Chrome on Cloudflare's global network for browser automation, screenshots, PDFs, web scraping, testing, and content generation.
|
||||||
|
|
||||||
|
**When to use**: Any task involving Cloudflare Browser Rendering including: taking screenshots, generating PDFs, web scraping, browser automation, testing web applications, extracting structured data, capturing page metrics, or automating browser interactions.
|
||||||
|
|
||||||
|
## Decision Tree
|
||||||
|
|
||||||
|
### REST API vs Workers Bindings
|
||||||
|
|
||||||
|
**Use REST API when:**
|
||||||
|
- One-off, stateless tasks (screenshot, PDF, content fetch)
|
||||||
|
- No Workers infrastructure yet
|
||||||
|
- Simple integrations from external services
|
||||||
|
- Need quick prototyping without deployment
|
||||||
|
|
||||||
|
**Use Workers Bindings when:**
|
||||||
|
- Complex browser automation workflows
|
||||||
|
- Need session reuse for performance
|
||||||
|
- Multiple page interactions per request
|
||||||
|
- Custom scripting and logic required
|
||||||
|
- Building production applications
|
||||||
|
|
||||||
|
### Puppeteer vs Playwright
|
||||||
|
|
||||||
|
| Feature | Puppeteer | Playwright |
|
||||||
|
|---------|-----------|------------|
|
||||||
|
| API Style | Chrome DevTools Protocol | High-level abstractions |
|
||||||
|
| Selectors | CSS, XPath | CSS, text, role, test-id |
|
||||||
|
| Best for | Advanced control, CDP access | Quick automation, testing |
|
||||||
|
| Learning curve | Steeper | Gentler |
|
||||||
|
|
||||||
|
**Use Puppeteer:** Need CDP protocol access, Chrome-specific features, migration from existing Puppeteer code
|
||||||
|
**Use Playwright:** Modern selector APIs, cross-browser patterns, faster development
|
||||||
|
|
||||||
|
## Tier Limits Summary
|
||||||
|
|
||||||
|
| Limit | Free Tier | Paid Tier |
|
||||||
|
|-------|-----------|-----------|
|
||||||
|
| Daily browser time | 10 minutes | Unlimited* |
|
||||||
|
| Concurrent sessions | 3 | 30 |
|
||||||
|
| Requests per minute | 6 | 180 |
|
||||||
|
|
||||||
|
*Subject to fair-use policy. See [gotchas.md](gotchas.md) for details.
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
**New to Browser Rendering:**
|
||||||
|
1. [configuration.md](configuration.md) - Setup and deployment
|
||||||
|
2. [patterns.md](patterns.md) - Common use cases with examples
|
||||||
|
3. [api.md](api.md) - API reference
|
||||||
|
4. [gotchas.md](gotchas.md) - Avoid common pitfalls
|
||||||
|
|
||||||
|
**Specific task:**
|
||||||
|
- **Setup/deployment** → [configuration.md](configuration.md)
|
||||||
|
- **API reference/endpoints** → [api.md](api.md)
|
||||||
|
- **Example code/patterns** → [patterns.md](patterns.md)
|
||||||
|
- **Debugging/troubleshooting** → [gotchas.md](gotchas.md)
|
||||||
|
|
||||||
|
**REST API users:**
|
||||||
|
- Start with [api.md](api.md) REST API section
|
||||||
|
- Check [gotchas.md](gotchas.md) for rate limits
|
||||||
|
|
||||||
|
**Workers users:**
|
||||||
|
- Start with [configuration.md](configuration.md)
|
||||||
|
- Review [patterns.md](patterns.md) for session management
|
||||||
|
- Reference [api.md](api.md) for Workers Bindings
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
- **[configuration.md](configuration.md)** - Setup, deployment, wrangler config, compatibility
|
||||||
|
- **[api.md](api.md)** - REST API endpoints + Workers Bindings (Puppeteer/Playwright)
|
||||||
|
- **[patterns.md](patterns.md)** - Common patterns, use cases, real examples
|
||||||
|
- **[gotchas.md](gotchas.md)** - Troubleshooting, best practices, tier limits, common errors
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Cloudflare Docs](https://developers.cloudflare.com/browser-rendering/)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Browser Rendering API
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
**Base:** `https://api.cloudflare.com/client/v4/accounts/{accountId}/browser-rendering`
|
||||||
|
**Auth:** `Authorization: Bearer <token>` (Browser Rendering - Edit permission)
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Description | Key Options |
|
||||||
|
|----------|-------------|-------------|
|
||||||
|
| `/content` | Get rendered HTML | `url`, `waitUntil` |
|
||||||
|
| `/screenshot` | Capture image | `screenshotOptions: {type, fullPage, clip}` |
|
||||||
|
| `/pdf` | Generate PDF | `pdfOptions: {format, landscape, margin}` |
|
||||||
|
| `/snapshot` | HTML + inlined resources | `url` |
|
||||||
|
| `/scrape` | Extract by selectors | `selectors: ["h1", ".price"]` |
|
||||||
|
| `/json` | AI-structured extraction | `schema: {name: "string", price: "number"}` |
|
||||||
|
| `/links` | Get all links | `url` |
|
||||||
|
| `/markdown` | Convert to markdown | `url` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST '.../browser-rendering/screenshot' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"url":"https://example.com","screenshotOptions":{"fullPage":true}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workers Binding
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// wrangler.jsonc
|
||||||
|
{ "browser": { "binding": "MYBROWSER" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Puppeteer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import puppeteer from "@cloudflare/puppeteer";
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch(env.MYBROWSER, { keep_alive: 600000 });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('https://example.com', { waitUntil: 'networkidle0' });
|
||||||
|
|
||||||
|
// Content
|
||||||
|
const html = await page.content();
|
||||||
|
const title = await page.title();
|
||||||
|
|
||||||
|
// Screenshot/PDF
|
||||||
|
await page.screenshot({ fullPage: true, type: 'png' });
|
||||||
|
await page.pdf({ format: 'A4', printBackground: true });
|
||||||
|
|
||||||
|
// Interaction
|
||||||
|
await page.click('#button');
|
||||||
|
await page.type('#input', 'text');
|
||||||
|
await page.evaluate(() => document.querySelector('h1')?.textContent);
|
||||||
|
|
||||||
|
// Session management
|
||||||
|
const sessions = await puppeteer.sessions(env.MYBROWSER);
|
||||||
|
const limits = await puppeteer.limits(env.MYBROWSER);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playwright
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { launch, connect } from "@cloudflare/playwright";
|
||||||
|
|
||||||
|
const browser = await launch(env.MYBROWSER, { keep_alive: 600000 });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
await page.goto('https://example.com', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
// Modern selectors
|
||||||
|
await page.locator('.button').click();
|
||||||
|
await page.getByText('Submit').click();
|
||||||
|
await page.getByTestId('search').fill('query');
|
||||||
|
|
||||||
|
// Context for isolation
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
userAgent: 'custom'
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// List sessions
|
||||||
|
await puppeteer.sessions(env.MYBROWSER);
|
||||||
|
|
||||||
|
// Connect to existing
|
||||||
|
await puppeteer.connect(env.MYBROWSER, sessionId);
|
||||||
|
|
||||||
|
// Check limits
|
||||||
|
await puppeteer.limits(env.MYBROWSER);
|
||||||
|
// { remaining: ms, total: ms, concurrent: n }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Options
|
||||||
|
|
||||||
|
| Option | Values |
|
||||||
|
|--------|--------|
|
||||||
|
| `waitUntil` | `load`, `domcontentloaded`, `networkidle0`, `networkidle2` |
|
||||||
|
| `keep_alive` | Max 600000ms (10 min) |
|
||||||
|
| `screenshot.type` | `png`, `jpeg` |
|
||||||
|
| `pdf.format` | `A4`, `Letter`, `Legal` |
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Configuration & Setup
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @cloudflare/puppeteer # or @cloudflare/playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cloudflare packages** - standard `puppeteer`/`playwright` won't work in Workers.
|
||||||
|
|
||||||
|
## wrangler.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "browser-worker",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2025-01-01",
|
||||||
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
|
"browser": {
|
||||||
|
"binding": "MYBROWSER"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required:** `nodejs_compat` flag and `browser.binding`.
|
||||||
|
|
||||||
|
## TypeScript
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Env {
|
||||||
|
MYBROWSER: Fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
} satisfies ExportedHandler<Env>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler dev --remote # --remote required for browser binding
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local mode does NOT support Browser Rendering** - must use `--remote`.
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
No wrangler config needed. Get API token with "Browser Rendering - Edit" permission.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
'https://api.cloudflare.com/client/v4/accounts/{accountId}/browser-rendering/screenshot' \
|
||||||
|
-H 'Authorization: Bearer TOKEN' \
|
||||||
|
-d '{"url": "https://example.com"}' --output screenshot.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Requirement | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| Node.js compatibility | `nodejs_compat` flag |
|
||||||
|
| Compatibility date | 2023-03-01+ |
|
||||||
|
| Module format | ES modules only |
|
||||||
|
| Browser | Chromium 119+ (no Firefox/Safari) |
|
||||||
|
|
||||||
|
**Not supported:** WebGL, WebRTC, extensions, `file://` protocol, Service Worker syntax.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Error | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| `MYBROWSER is undefined` | Use `wrangler dev --remote` |
|
||||||
|
| `nodejs_compat not enabled` | Add to `compatibility_flags` |
|
||||||
|
| `Module not found` | `npm install @cloudflare/puppeteer` |
|
||||||
|
| `Browser Rendering not available` | Enable in dashboard |
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Browser Rendering Gotchas
|
||||||
|
|
||||||
|
## Tier Limits
|
||||||
|
|
||||||
|
| Limit | Free | Paid |
|
||||||
|
|-------|------|------|
|
||||||
|
| Daily browser time | 10 min | Unlimited* |
|
||||||
|
| Concurrent sessions | 3 | 30 |
|
||||||
|
| Requests/minute | 6 | 180 |
|
||||||
|
| Session keep-alive | 10 min max | 10 min max |
|
||||||
|
|
||||||
|
*Subject to fair-use policy.
|
||||||
|
|
||||||
|
**Check quota:**
|
||||||
|
```typescript
|
||||||
|
const limits = await puppeteer.limits(env.MYBROWSER);
|
||||||
|
// { remaining: 540000, total: 600000, concurrent: 2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Always Close Browsers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const browser = await puppeteer.launch(env.MYBROWSER);
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto("https://example.com");
|
||||||
|
return new Response(await page.content());
|
||||||
|
} finally {
|
||||||
|
await browser.close(); // ALWAYS in finally
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workers vs REST:** REST auto-closes after timeout. Workers must call `close()` or session stays open until `keep_alive` expires.
|
||||||
|
|
||||||
|
## Optimize Concurrency
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 3 sessions (hits free tier limit)
|
||||||
|
const browser1 = await puppeteer.launch(env.MYBROWSER);
|
||||||
|
const browser2 = await puppeteer.launch(env.MYBROWSER);
|
||||||
|
|
||||||
|
// ✅ 1 session, multiple pages
|
||||||
|
const browser = await puppeteer.launch(env.MYBROWSER);
|
||||||
|
const page1 = await browser.newPage();
|
||||||
|
const page2 = await browser.newPage();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
| Error | Cause | Fix |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| Session limit exceeded | Too many concurrent | Close unused browsers, use pages not browsers |
|
||||||
|
| Page navigation timeout | Slow page or `networkidle` on busy page | Increase timeout, use `waitUntil: "load"` |
|
||||||
|
| Session not found | Expired session | Catch error, launch new session |
|
||||||
|
| Evaluation failed | DOM element missing | Use `?.` optional chaining |
|
||||||
|
| Protocol error: Target closed | Page closed during operation | Await all ops before closing |
|
||||||
|
|
||||||
|
## page.evaluate() Gotchas
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Outer scope not available
|
||||||
|
const selector = "h1";
|
||||||
|
await page.evaluate(() => document.querySelector(selector));
|
||||||
|
|
||||||
|
// ✅ Pass as argument
|
||||||
|
await page.evaluate((sel) => document.querySelector(sel)?.textContent, selector);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
**waitUntil options (fastest to slowest):**
|
||||||
|
1. `domcontentloaded` - DOM ready
|
||||||
|
2. `load` - load event (default)
|
||||||
|
3. `networkidle0` - no network for 500ms
|
||||||
|
|
||||||
|
**Block unnecessary resources:**
|
||||||
|
```typescript
|
||||||
|
await page.setRequestInterception(true);
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (["image", "stylesheet", "font"].includes(req.resourceType())) {
|
||||||
|
req.abort();
|
||||||
|
} else {
|
||||||
|
req.continue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Session reuse:** Cold start ~1-2s, warm connect ~100-200ms. Store sessionId in KV for reuse.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Browser Rendering Patterns
|
||||||
|
|
||||||
|
## Basic Worker
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import puppeteer from "@cloudflare/puppeteer";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const browser = await puppeteer.launch(env.MYBROWSER);
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto("https://example.com");
|
||||||
|
return new Response(await page.content());
|
||||||
|
} finally {
|
||||||
|
await browser.close(); // ALWAYS in finally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Reuse
|
||||||
|
|
||||||
|
Keep sessions alive for performance:
|
||||||
|
```typescript
|
||||||
|
let sessionId = await env.SESSION_KV.get("browser-session");
|
||||||
|
if (sessionId) {
|
||||||
|
browser = await puppeteer.connect(env.MYBROWSER, sessionId);
|
||||||
|
} else {
|
||||||
|
browser = await puppeteer.launch(env.MYBROWSER, { keep_alive: 600000 });
|
||||||
|
await env.SESSION_KV.put("browser-session", browser.sessionId(), { expirationTtl: 600 });
|
||||||
|
}
|
||||||
|
// Don't close browser to keep session alive
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Operations
|
||||||
|
|
||||||
|
| Task | Code |
|
||||||
|
|------|------|
|
||||||
|
| Screenshot | `await page.screenshot({ type: "png", fullPage: true })` |
|
||||||
|
| PDF | `await page.pdf({ format: "A4", printBackground: true })` |
|
||||||
|
| Extract data | `await page.evaluate(() => document.querySelector('h1').textContent)` |
|
||||||
|
| Fill form | `await page.type('#input', 'value'); await page.click('button')` |
|
||||||
|
| Wait nav | `await Promise.all([page.waitForNavigation(), page.click('a')])` |
|
||||||
|
|
||||||
|
## Parallel Scraping
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const pages = await Promise.all(urls.map(() => browser.newPage()));
|
||||||
|
await Promise.all(pages.map((p, i) => p.goto(urls[i])));
|
||||||
|
const titles = await Promise.all(pages.map(p => p.title()));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playwright Selectors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { launch } from "@cloudflare/playwright";
|
||||||
|
const browser = await launch(env.MYBROWSER);
|
||||||
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
|
await page.getByLabel("Email").fill("user@example.com");
|
||||||
|
await page.getByTestId("submit-button").click();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Incognito Contexts
|
||||||
|
|
||||||
|
Isolated sessions without multiple browsers:
|
||||||
|
```typescript
|
||||||
|
const ctx1 = await browser.createIncognitoBrowserContext();
|
||||||
|
const ctx2 = await browser.createIncognitoBrowserContext();
|
||||||
|
// Each has isolated cookies/storage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quota Check
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const limits = await puppeteer.limits(env.MYBROWSER);
|
||||||
|
if (limits.remaining < 60000) return new Response("Quota low", { status: 429 });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
await page.goto(url, { timeout: 30000, waitUntil: "networkidle0" });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("timeout")) return new Response("Timeout", { status: 504 });
|
||||||
|
if (e.message.includes("Session limit")) return new Response("Too many sessions", { status: 429 });
|
||||||
|
} finally {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# C3 (create-cloudflare)
|
||||||
|
|
||||||
|
Official CLI for scaffolding Cloudflare Workers and Pages projects with templates, TypeScript, and instant deployment.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive (recommended for first-time)
|
||||||
|
npm create cloudflare@latest my-app
|
||||||
|
|
||||||
|
# Worker (API/WebSocket/Cron)
|
||||||
|
npm create cloudflare@latest my-api -- --type=hello-world --ts
|
||||||
|
|
||||||
|
# Pages (static/SSG/full-stack)
|
||||||
|
npm create cloudflare@latest my-site -- --type=web-app --framework=astro --platform=pages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
What are you building?
|
||||||
|
|
||||||
|
├─ API / WebSocket / Cron / Email handler
|
||||||
|
│ └─ Workers (default) - no --platform flag needed
|
||||||
|
│ npm create cloudflare@latest my-api -- --type=hello-world
|
||||||
|
|
||||||
|
├─ Static site / SSG / Documentation
|
||||||
|
│ └─ Pages - requires --platform=pages
|
||||||
|
│ npm create cloudflare@latest my-site -- --type=web-app --framework=astro --platform=pages
|
||||||
|
|
||||||
|
├─ Full-stack app (Next.js/Remix/SvelteKit)
|
||||||
|
│ ├─ Need Durable Objects, Queues, or Workers-only features?
|
||||||
|
│ │ └─ Workers (default)
|
||||||
|
│ └─ Otherwise use Pages for git integration and branch previews
|
||||||
|
│ └─ Add --platform=pages
|
||||||
|
|
||||||
|
└─ Convert existing project
|
||||||
|
└─ npm create cloudflare@latest . -- --type=pre-existing --existing-script=./src/worker.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical:** Pages projects require `--platform=pages` flag. Without it, C3 defaults to Workers.
|
||||||
|
|
||||||
|
## Interactive Flow
|
||||||
|
|
||||||
|
When run without flags, C3 prompts in this order:
|
||||||
|
|
||||||
|
1. **Project name** - Directory to create (defaults to current dir with `.`)
|
||||||
|
2. **Application type** - `hello-world`, `web-app`, `demo`, `pre-existing`, `remote-template`
|
||||||
|
3. **Platform** - `workers` (default) or `pages` (for web apps only)
|
||||||
|
4. **Framework** - If web-app: `next`, `remix`, `astro`, `react-router`, `solid`, `svelte`, etc.
|
||||||
|
5. **TypeScript** - `yes` (recommended) or `no`
|
||||||
|
6. **Git** - Initialize repository? `yes` or `no`
|
||||||
|
7. **Deploy** - Deploy now? `yes` or `no` (requires `wrangler login`)
|
||||||
|
|
||||||
|
## Installation Methods
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# NPM
|
||||||
|
npm create cloudflare@latest
|
||||||
|
|
||||||
|
# Yarn
|
||||||
|
yarn create cloudflare
|
||||||
|
|
||||||
|
# PNPM
|
||||||
|
pnpm create cloudflare@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
| File | Purpose | Use When |
|
||||||
|
|------|---------|----------|
|
||||||
|
| **api.md** | Complete CLI flag reference | Scripting, CI/CD, advanced usage |
|
||||||
|
| **configuration.md** | Generated files, bindings, types | Understanding output, customization |
|
||||||
|
| **patterns.md** | Workflows, CI/CD, monorepos | Real-world integration |
|
||||||
|
| **gotchas.md** | Troubleshooting failures | Deployment blocked, errors |
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
| Task | Read |
|
||||||
|
|------|------|
|
||||||
|
| Create first project | README only |
|
||||||
|
| Set up CI/CD | README → api → patterns |
|
||||||
|
| Debug failed deploy | gotchas |
|
||||||
|
| Understand generated files | configuration |
|
||||||
|
| Full CLI reference | api |
|
||||||
|
| Create custom template | patterns → configuration |
|
||||||
|
| Convert existing project | README → patterns |
|
||||||
|
|
||||||
|
## Post-Creation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd my-app
|
||||||
|
|
||||||
|
# Local dev with hot reload
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Generate TypeScript types for bindings
|
||||||
|
npm run cf-typegen
|
||||||
|
|
||||||
|
# Deploy to Cloudflare
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- **workers/README.md** - Workers runtime, bindings, APIs
|
||||||
|
- **workers-ai/README.md** - AI/ML models
|
||||||
|
- **pages/README.md** - Pages-specific features
|
||||||
|
- **wrangler/README.md** - Wrangler CLI beyond initial setup
|
||||||
|
- **d1/README.md** - SQLite database
|
||||||
|
- **r2/README.md** - Object storage
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# C3 CLI Reference
|
||||||
|
|
||||||
|
## Invocation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm create cloudflare@latest [name] [-- flags] # NPM requires --
|
||||||
|
yarn create cloudflare [name] [flags]
|
||||||
|
pnpm create cloudflare@latest [name] [-- flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Flags
|
||||||
|
|
||||||
|
| Flag | Values | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `--type` | `hello-world`, `web-app`, `demo`, `pre-existing`, `remote-template` | Application type |
|
||||||
|
| `--platform` | `workers` (default), `pages` | Target platform |
|
||||||
|
| `--framework` | `next`, `remix`, `astro`, `react-router`, `solid`, `svelte`, `qwik`, `vue`, `angular`, `hono` | Web framework (requires `--type=web-app`) |
|
||||||
|
| `--lang` | `ts`, `js`, `python` | Language (for `--type=hello-world`) |
|
||||||
|
| `--ts` / `--no-ts` | - | TypeScript for web apps |
|
||||||
|
|
||||||
|
## Deployment Flags
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `--deploy` / `--no-deploy` | Deploy immediately (prompts interactive, skips in CI) |
|
||||||
|
| `--git` / `--no-git` | Initialize git (default: yes) |
|
||||||
|
| `--open` | Open browser after deploy |
|
||||||
|
|
||||||
|
## Advanced Flags
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `--template=user/repo` | GitHub template or local path |
|
||||||
|
| `--existing-script=./src/worker.ts` | Existing script (requires `--type=pre-existing`) |
|
||||||
|
| `--category=ai\|database\|realtime` | Demo filter (requires `--type=demo`) |
|
||||||
|
| `--experimental` | Enable experimental features |
|
||||||
|
| `--wrangler-defaults` | Skip wrangler prompts |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLOUDFLARE_API_TOKEN=xxx # For deployment
|
||||||
|
CLOUDFLARE_ACCOUNT_ID=xxx # Account ID
|
||||||
|
CF_TELEMETRY_DISABLED=1 # Disable telemetry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exit Codes
|
||||||
|
|
||||||
|
`0` success, `1` user abort, `2` error
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TypeScript Worker
|
||||||
|
npm create cloudflare@latest my-api -- --type=hello-world --lang=ts --no-deploy
|
||||||
|
|
||||||
|
# Next.js on Pages
|
||||||
|
npm create cloudflare@latest my-app -- --type=web-app --framework=next --platform=pages --ts
|
||||||
|
|
||||||
|
# Astro blog
|
||||||
|
npm create cloudflare@latest my-blog -- --type=web-app --framework=astro --ts --deploy
|
||||||
|
|
||||||
|
# CI: non-interactive
|
||||||
|
npm create cloudflare@latest my-app -- --type=web-app --framework=next --ts --no-git --no-deploy
|
||||||
|
|
||||||
|
# GitHub template
|
||||||
|
npm create cloudflare@latest -- --template=cloudflare/templates/worker-openapi
|
||||||
|
|
||||||
|
# Convert existing project
|
||||||
|
npm create cloudflare@latest . -- --type=pre-existing --existing-script=./build/worker.js
|
||||||
|
```
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# C3 Generated Configuration
|
||||||
|
|
||||||
|
## Output Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/index.ts # Worker entry point
|
||||||
|
├── wrangler.jsonc # Cloudflare config
|
||||||
|
├── package.json # Scripts
|
||||||
|
├── tsconfig.json
|
||||||
|
└── .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
## wrangler.jsonc
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/cloudflare/workers-sdk/main/packages/wrangler/config-schema.json",
|
||||||
|
"name": "my-app",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2026-01-27"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Binding Placeholders
|
||||||
|
|
||||||
|
C3 generates **placeholder IDs** that must be replaced before deploy:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"kv_namespaces": [{ "binding": "MY_KV", "id": "placeholder_kv_id" }],
|
||||||
|
"d1_databases": [{ "binding": "DB", "database_id": "00000000-..." }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace with real IDs:**
|
||||||
|
```bash
|
||||||
|
npx wrangler kv namespace create MY_KV # Returns real ID
|
||||||
|
npx wrangler d1 create my-database # Returns real database_id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deployment error if not replaced:**
|
||||||
|
```
|
||||||
|
Error: Invalid KV namespace ID "placeholder_kv_id"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"cf-typegen": "wrangler types"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Generation
|
||||||
|
|
||||||
|
Run after adding bindings:
|
||||||
|
```bash
|
||||||
|
npm run cf-typegen
|
||||||
|
```
|
||||||
|
|
||||||
|
Generates `.wrangler/types/runtime.d.ts`:
|
||||||
|
```typescript
|
||||||
|
interface Env {
|
||||||
|
MY_KV: KVNamespace;
|
||||||
|
DB: D1Database;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-Creation Checklist
|
||||||
|
|
||||||
|
1. Review `wrangler.jsonc` - check name, compatibility_date
|
||||||
|
2. Replace placeholder binding IDs with real resource IDs
|
||||||
|
3. Run `npm run cf-typegen`
|
||||||
|
4. Test: `npm run dev`
|
||||||
|
5. Deploy: `npm run deploy`
|
||||||
|
6. Add secrets: `npx wrangler secret put SECRET_NAME`
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# C3 Troubleshooting
|
||||||
|
|
||||||
|
## Deployment Issues
|
||||||
|
|
||||||
|
### Placeholder IDs
|
||||||
|
|
||||||
|
**Error:** "Invalid namespace ID"
|
||||||
|
**Fix:** Replace placeholders in wrangler.jsonc with real IDs:
|
||||||
|
```bash
|
||||||
|
npx wrangler kv namespace create MY_KV # Get real ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
**Error:** "Not authenticated"
|
||||||
|
**Fix:** `npx wrangler login` or set `CLOUDFLARE_API_TOKEN`
|
||||||
|
|
||||||
|
### Name Conflict
|
||||||
|
|
||||||
|
**Error:** "Worker already exists"
|
||||||
|
**Fix:** Change `name` in wrangler.jsonc
|
||||||
|
|
||||||
|
## Platform Selection
|
||||||
|
|
||||||
|
| Need | Platform |
|
||||||
|
|------|----------|
|
||||||
|
| Git integration, branch previews | `--platform=pages` |
|
||||||
|
| Durable Objects, D1, Queues | Workers (default) |
|
||||||
|
|
||||||
|
Wrong platform? Recreate with correct `--platform` flag.
|
||||||
|
|
||||||
|
## TypeScript Issues
|
||||||
|
|
||||||
|
**"Cannot find name 'KVNamespace'"**
|
||||||
|
```bash
|
||||||
|
npm run cf-typegen # Regenerate types
|
||||||
|
# Restart TS server in editor
|
||||||
|
```
|
||||||
|
|
||||||
|
**Missing types after config change:** Re-run `npm run cf-typegen`
|
||||||
|
|
||||||
|
## Package Manager
|
||||||
|
|
||||||
|
**Multiple lockfiles causing issues:**
|
||||||
|
```bash
|
||||||
|
rm pnpm-lock.yaml # If using npm
|
||||||
|
rm package-lock.json # If using pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
**CI hangs on prompts:**
|
||||||
|
```bash
|
||||||
|
npm create cloudflare@latest my-app -- \
|
||||||
|
--type=hello-world --lang=ts --no-git --no-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth in CI:**
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Framework-Specific
|
||||||
|
|
||||||
|
| Framework | Issue | Fix |
|
||||||
|
|-----------|-------|-----|
|
||||||
|
| Next.js | create-next-app failed | `npm cache clean --force`, retry |
|
||||||
|
| Astro | Adapter missing | Install `@astrojs/cloudflare` |
|
||||||
|
| Remix | Module errors | Update `@remix-run/cloudflare*` |
|
||||||
|
|
||||||
|
## Compatibility Date
|
||||||
|
|
||||||
|
**"Feature X requires compatibility_date >= ..."**
|
||||||
|
**Fix:** Update `compatibility_date` in wrangler.jsonc to today's date
|
||||||
|
|
||||||
|
## Node.js Version
|
||||||
|
|
||||||
|
**"Node.js version not supported"**
|
||||||
|
**Fix:** Install Node.js 18+ (`nvm install 20`)
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Error | Cause | Fix |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| Invalid namespace ID | Placeholder binding | Create resource, update config |
|
||||||
|
| Not authenticated | No login | `npx wrangler login` |
|
||||||
|
| Cannot find KVNamespace | Missing types | `npm run cf-typegen` |
|
||||||
|
| Worker already exists | Name conflict | Change `name` |
|
||||||
|
| CI hangs | Missing flags | Add --type, --lang, --no-deploy |
|
||||||
|
| Template not found | Bad name | Check cloudflare/templates |
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# C3 Usage Patterns
|
||||||
|
|
||||||
|
## Quick Workflows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TypeScript API Worker
|
||||||
|
npm create cloudflare@latest my-api -- --type=hello-world --lang=ts --deploy
|
||||||
|
|
||||||
|
# Next.js on Pages
|
||||||
|
npm create cloudflare@latest my-app -- --type=web-app --framework=next --platform=pages --ts --deploy
|
||||||
|
|
||||||
|
# Astro static site
|
||||||
|
npm create cloudflare@latest my-blog -- --type=web-app --framework=astro --platform=pages --ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD (GitHub Actions)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Deploy
|
||||||
|
run: npm run deploy
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Non-interactive requires:**
|
||||||
|
```bash
|
||||||
|
--type=<value> # Required
|
||||||
|
--no-git # Recommended (CI already in git)
|
||||||
|
--no-deploy # Deploy separately with secrets
|
||||||
|
--framework=<value> # For web-app
|
||||||
|
--ts / --no-ts # Required
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monorepo
|
||||||
|
|
||||||
|
C3 detects workspace config (`package.json` workspaces or `pnpm-workspace.yaml`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/
|
||||||
|
npm create cloudflare@latest my-worker -- --type=hello-world --lang=ts --no-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Templates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# GitHub repo
|
||||||
|
npm create cloudflare@latest -- --template=username/repo
|
||||||
|
npm create cloudflare@latest -- --template=cloudflare/templates/worker-openapi
|
||||||
|
|
||||||
|
# Local path
|
||||||
|
npm create cloudflare@latest my-app -- --template=../my-template
|
||||||
|
```
|
||||||
|
|
||||||
|
**Template requires `c3.config.json`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-template",
|
||||||
|
"category": "hello-world",
|
||||||
|
"copies": [{ "path": "src/" }, { "path": "wrangler.jsonc" }],
|
||||||
|
"transforms": [{ "path": "package.json", "jsonc": { "name": "{{projectName}}" }}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Existing Projects
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add Cloudflare to existing Worker
|
||||||
|
npm create cloudflare@latest . -- --type=pre-existing --existing-script=./dist/index.js
|
||||||
|
|
||||||
|
# Add to existing framework app
|
||||||
|
npm create cloudflare@latest . -- --type=web-app --framework=next --platform=pages --ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-Creation Checklist
|
||||||
|
|
||||||
|
1. Review `wrangler.jsonc` - set `compatibility_date`, verify `name`
|
||||||
|
2. Create bindings: `wrangler kv namespace create`, `wrangler d1 create`, `wrangler r2 bucket create`
|
||||||
|
3. Generate types: `npm run cf-typegen`
|
||||||
|
4. Test: `npm run dev`
|
||||||
|
5. Deploy: `npm run deploy`
|
||||||
|
6. Set secrets: `wrangler secret put SECRET_NAME`
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Cloudflare Cache Reserve
|
||||||
|
|
||||||
|
**Persistent cache storage built on R2 for long-term content retention**
|
||||||
|
|
||||||
|
## Smart Shield Integration
|
||||||
|
|
||||||
|
Cache Reserve is part of **Smart Shield**, Cloudflare's comprehensive security and performance suite:
|
||||||
|
|
||||||
|
- **Smart Shield Advanced tier**: Includes 2TB Cache Reserve storage
|
||||||
|
- **Standalone purchase**: Available separately if not using Smart Shield
|
||||||
|
- **Migration**: Existing standalone customers can migrate to Smart Shield bundles
|
||||||
|
|
||||||
|
**Decision**: Already on Smart Shield Advanced? Cache Reserve is included. Otherwise evaluate standalone purchase vs Smart Shield upgrade.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Cache Reserve is Cloudflare's persistent, large-scale cache storage layer built on R2. It acts as the ultimate upper-tier cache, storing cacheable content for extended periods (30+ days) to maximize cache hits, reduce origin egress fees, and shield origins from repeated requests for long-tail content.
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### What is Cache Reserve?
|
||||||
|
|
||||||
|
- **Persistent storage layer**: Built on R2, sits above tiered cache hierarchy
|
||||||
|
- **Long-term retention**: 30-day default retention, extended on each access
|
||||||
|
- **Automatic operation**: Works seamlessly with existing CDN, no code changes required
|
||||||
|
- **Origin shielding**: Dramatically reduces origin egress by serving cached content longer
|
||||||
|
- **Usage-based pricing**: Pay only for storage + read/write operations
|
||||||
|
|
||||||
|
### Cache Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
Visitor Request
|
||||||
|
↓
|
||||||
|
Lower-Tier Cache (closest to visitor)
|
||||||
|
↓ (on miss)
|
||||||
|
Upper-Tier Cache (closest to origin)
|
||||||
|
↓ (on miss)
|
||||||
|
Cache Reserve (R2 persistent storage)
|
||||||
|
↓ (on miss)
|
||||||
|
Origin Server
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **On cache miss**: Content fetched from origin �� written to Cache Reserve + edge caches simultaneously
|
||||||
|
2. **On edge eviction**: Content may be evicted from edge cache but remains in Cache Reserve
|
||||||
|
3. **On subsequent request**: If edge cache misses but Cache Reserve hits → content restored to edge caches
|
||||||
|
4. **Retention**: Assets remain in Cache Reserve for 30 days since last access (configurable via TTL)
|
||||||
|
|
||||||
|
## When to Use Cache Reserve
|
||||||
|
|
||||||
|
```
|
||||||
|
Need persistent caching?
|
||||||
|
├─ High origin egress costs → Cache Reserve ✓
|
||||||
|
├─ Long-tail content (archives, media libraries) → Cache Reserve ✓
|
||||||
|
├─ Already using Smart Shield Advanced → Included! ✓
|
||||||
|
├─ Video streaming with seeking (range requests) → ✗ Not supported
|
||||||
|
├─ Dynamic/personalized content → ✗ Use edge cache only
|
||||||
|
├─ Need per-request cache control from Workers → ✗ Use R2 directly
|
||||||
|
└─ Frequently updated content (< 10hr lifetime) → ✗ Not eligible
|
||||||
|
```
|
||||||
|
|
||||||
|
## Asset Eligibility
|
||||||
|
|
||||||
|
Cache Reserve only stores assets meeting **ALL** criteria:
|
||||||
|
|
||||||
|
- Cacheable per Cloudflare's standard rules
|
||||||
|
- Minimum 10-hour TTL (36000 seconds)
|
||||||
|
- `Content-Length` header present
|
||||||
|
- Original files only (not transformed images)
|
||||||
|
|
||||||
|
### Eligibility Checklist
|
||||||
|
|
||||||
|
Use this checklist to verify if an asset is eligible:
|
||||||
|
|
||||||
|
- [ ] Zone has Cache Reserve enabled
|
||||||
|
- [ ] Zone has Tiered Cache enabled (required)
|
||||||
|
- [ ] Asset TTL ≥ 10 hours (36,000 seconds)
|
||||||
|
- [ ] `Content-Length` header present on origin response
|
||||||
|
- [ ] No `Set-Cookie` header (or uses private directive)
|
||||||
|
- [ ] `Vary` header is NOT `*` (can be `Accept-Encoding`)
|
||||||
|
- [ ] Not an image transformation variant (original images OK)
|
||||||
|
- [ ] Not a range request (no HTTP 206 support)
|
||||||
|
- [ ] Not O2O (Orange-to-Orange) proxied request
|
||||||
|
|
||||||
|
**All boxes must be checked for Cache Reserve eligibility.**
|
||||||
|
|
||||||
|
### Not Eligible
|
||||||
|
|
||||||
|
- Assets with TTL < 10 hours
|
||||||
|
- Responses without `Content-Length` header
|
||||||
|
- Image transformation variants (original images are eligible)
|
||||||
|
- Responses with `Set-Cookie` headers
|
||||||
|
- Responses with `Vary: *` header
|
||||||
|
- Assets from R2 public buckets on same zone
|
||||||
|
- O2O (Orange-to-Orange) setup requests
|
||||||
|
- **Range requests** (video seeking, partial content downloads)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable via Dashboard
|
||||||
|
https://dash.cloudflare.com/caching/cache-reserve
|
||||||
|
# Click "Enable Storage Sync" or "Purchase" button
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Paid Cache Reserve plan or Smart Shield Advanced required
|
||||||
|
- Tiered Cache required for optimal performance
|
||||||
|
|
||||||
|
## Essential Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Cache Reserve status
|
||||||
|
curl -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/cache/cache_reserve" \
|
||||||
|
-H "Authorization: Bearer $API_TOKEN"
|
||||||
|
|
||||||
|
# Enable Cache Reserve
|
||||||
|
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/cache/cache_reserve" \
|
||||||
|
-H "Authorization: Bearer $API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"value": "on"}'
|
||||||
|
|
||||||
|
# Check asset cache status
|
||||||
|
curl -I https://example.com/asset.jpg | grep -i cache
|
||||||
|
```
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
| Task | Files |
|
||||||
|
|------|-------|
|
||||||
|
| Evaluate if Cache Reserve fits your use case | README.md (this file) |
|
||||||
|
| Enable Cache Reserve for your zone | README.md + [configuration.md](./configuration.md) |
|
||||||
|
| Use with Workers (understand limitations) | [api.md](./api.md) |
|
||||||
|
| Setup via SDKs or IaC (TypeScript, Python, Terraform) | [configuration.md](./configuration.md) |
|
||||||
|
| Optimize costs and debug issues | [patterns.md](./patterns.md) + [gotchas.md](./gotchas.md) |
|
||||||
|
| Understand eligibility and troubleshoot | [gotchas.md](./gotchas.md) → [patterns.md](./patterns.md) |
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- [configuration.md](./configuration.md) - Setup, API, SDKs, and Cache Rules
|
||||||
|
- [api.md](./api.md) - Purging, monitoring, Workers integration
|
||||||
|
- [patterns.md](./patterns.md) - Best practices, cost optimization, debugging
|
||||||
|
- [gotchas.md](./gotchas.md) - Common issues, limitations, troubleshooting
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
- [r2](../r2/) - Cache Reserve built on R2 storage
|
||||||
|
- [workers](../workers/) - Workers integration with Cache API
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Cache Reserve API
|
||||||
|
|
||||||
|
## Workers Integration
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CRITICAL: Workers Cache API ≠ Cache Reserve │
|
||||||
|
│ │
|
||||||
|
│ • Workers caches.default / cache.put() → edge cache ONLY │
|
||||||
|
│ • Cache Reserve → zone-level setting, automatic, no per-req │
|
||||||
|
│ • You CANNOT selectively write to Cache Reserve from Workers │
|
||||||
|
│ • Cache Reserve works with standard fetch(), not cache.put() │
|
||||||
|
└────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache Reserve is a **zone-level configuration**, not a per-request API. It works automatically when enabled for the zone:
|
||||||
|
|
||||||
|
### Standard Fetch (Recommended)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Cache Reserve works automatically via standard fetch
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
// Standard fetch uses Cache Reserve automatically
|
||||||
|
return await fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache API Limitations
|
||||||
|
|
||||||
|
**IMPORTANT**: `cache.put()` is **NOT compatible** with Cache Reserve or Tiered Cache.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG: cache.put() bypasses Cache Reserve
|
||||||
|
const cache = caches.default;
|
||||||
|
let response = await cache.match(request);
|
||||||
|
if (!response) {
|
||||||
|
response = await fetch(request);
|
||||||
|
await cache.put(request, response.clone()); // Bypasses Cache Reserve!
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ CORRECT: Use standard fetch for Cache Reserve compatibility
|
||||||
|
return await fetch(request);
|
||||||
|
|
||||||
|
// ✅ CORRECT: Use Cache API only for custom cache namespaces
|
||||||
|
const customCache = await caches.open('my-custom-cache');
|
||||||
|
let response = await customCache.match(request);
|
||||||
|
if (!response) {
|
||||||
|
response = await fetch(request);
|
||||||
|
await customCache.put(request, response.clone()); // Custom cache OK
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Purging and Cache Management
|
||||||
|
|
||||||
|
### Purge by URL (Instant)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Purge specific URL from Cache Reserve immediately
|
||||||
|
const purgeCacheReserveByURL = async (
|
||||||
|
zoneId: string,
|
||||||
|
apiToken: string,
|
||||||
|
urls: string[]
|
||||||
|
) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ files: urls })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example usage
|
||||||
|
await purgeCacheReserveByURL('zone123', 'token456', [
|
||||||
|
'https://example.com/image.jpg',
|
||||||
|
'https://example.com/video.mp4'
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Purge by Tag/Host/Prefix (Revalidation)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Purge by cache tag - forces revalidation, not immediate removal
|
||||||
|
await fetch(
|
||||||
|
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tags: ['tag1', 'tag2'] })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purge behavior:**
|
||||||
|
- **By URL**: Immediate removal from Cache Reserve + edge cache
|
||||||
|
- **By tag/host/prefix**: Revalidation only, assets remain in storage (costs continue)
|
||||||
|
|
||||||
|
### Clear All Cache Reserve Data
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Requires Cache Reserve OFF first
|
||||||
|
await fetch(
|
||||||
|
`https://api.cloudflare.com/client/v4/zones/${zoneId}/cache/cache_reserve_clear`,
|
||||||
|
{ method: 'POST', headers: { 'Authorization': `Bearer ${apiToken}` } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check status: GET same endpoint returns { state: "In-progress" | "Completed" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Process**: Disable Cache Reserve → Call clear endpoint → Wait up to 24hr → Re-enable
|
||||||
|
|
||||||
|
## Monitoring and Analytics
|
||||||
|
|
||||||
|
### Dashboard Analytics
|
||||||
|
|
||||||
|
Navigate to **Caching > Cache Reserve** to view:
|
||||||
|
|
||||||
|
- **Egress Savings**: Total bytes served from Cache Reserve vs origin egress cost saved
|
||||||
|
- **Requests Served**: Cache Reserve hits vs misses breakdown
|
||||||
|
- **Storage Used**: Current GB stored in Cache Reserve (billed monthly)
|
||||||
|
- **Operations**: Class A (writes) and Class B (reads) operation counts
|
||||||
|
- **Cost Tracking**: Estimated monthly costs based on current usage
|
||||||
|
|
||||||
|
### Logpush Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Logpush field: CacheReserveUsed (boolean) - filter for Cache Reserve hits
|
||||||
|
// Query Cache Reserve hits in analytics
|
||||||
|
const logpushQuery = `
|
||||||
|
SELECT
|
||||||
|
ClientRequestHost,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
SUM(EdgeResponseBytes) as bytes_served,
|
||||||
|
COUNT(CASE WHEN CacheReserveUsed = true THEN 1 END) as cache_reserve_hits,
|
||||||
|
COUNT(CASE WHEN CacheReserveUsed = false THEN 1 END) as cache_reserve_misses
|
||||||
|
FROM http_requests
|
||||||
|
WHERE Timestamp >= NOW() - INTERVAL '24 hours'
|
||||||
|
GROUP BY ClientRequestHost
|
||||||
|
ORDER BY requests DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Filter only Cache Reserve hits
|
||||||
|
const crHitsQuery = `
|
||||||
|
SELECT ClientRequestHost, COUNT(*) as requests, SUM(EdgeResponseBytes) as bytes
|
||||||
|
FROM http_requests
|
||||||
|
WHERE CacheReserveUsed = true AND Timestamp >= NOW() - INTERVAL '7 days'
|
||||||
|
GROUP BY ClientRequestHost
|
||||||
|
ORDER BY bytes DESC
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### GraphQL Analytics
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query CacheReserveAnalytics($zoneTag: string, $since: string, $until: string) {
|
||||||
|
viewer {
|
||||||
|
zones(filter: { zoneTag: $zoneTag }) {
|
||||||
|
httpRequests1dGroups(
|
||||||
|
filter: { datetime_geq: $since, datetime_leq: $until }
|
||||||
|
limit: 1000
|
||||||
|
) {
|
||||||
|
dimensions { date }
|
||||||
|
sum {
|
||||||
|
cachedBytes
|
||||||
|
cachedRequests
|
||||||
|
bytes
|
||||||
|
requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pricing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Storage: $0.015/GB-month | Class A (writes): $4.50/M | Class B (reads): $0.36/M
|
||||||
|
// Cache miss: 1A + 1B | Cache hit: 1B | Assets >1GB: proportionally more ops
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [README](./README.md) - Overview and core concepts
|
||||||
|
- [Configuration](./configuration.md) - Setup and Cache Rules
|
||||||
|
- [Patterns](./patterns.md) - Best practices and optimization
|
||||||
|
- [Gotchas](./gotchas.md) - Common issues and troubleshooting
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Cache Reserve Configuration
|
||||||
|
|
||||||
|
## Dashboard Setup
|
||||||
|
|
||||||
|
**Minimum steps to enable:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to dashboard
|
||||||
|
https://dash.cloudflare.com/caching/cache-reserve
|
||||||
|
|
||||||
|
# Click "Enable Storage Sync" or "Purchase" button
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Paid Cache Reserve plan or Smart Shield Advanced required
|
||||||
|
- Tiered Cache **required** for Cache Reserve to function optimally
|
||||||
|
|
||||||
|
## API Configuration
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable
|
||||||
|
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/cache/cache_reserve" \
|
||||||
|
-H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"value": "on"}'
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
curl -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/cache/cache_reserve" \
|
||||||
|
-H "Authorization: Bearer $API_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript SDK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install cloudflare
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Cloudflare from 'cloudflare';
|
||||||
|
|
||||||
|
const client = new Cloudflare({
|
||||||
|
apiToken: process.env.CLOUDFLARE_API_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable Cache Reserve
|
||||||
|
await client.cache.cacheReserve.edit({
|
||||||
|
zone_id: 'abc123',
|
||||||
|
value: 'on',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Cache Reserve status
|
||||||
|
const status = await client.cache.cacheReserve.get({
|
||||||
|
zone_id: 'abc123',
|
||||||
|
});
|
||||||
|
console.log(status.value); // 'on' or 'off'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python SDK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install cloudflare
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from cloudflare import Cloudflare
|
||||||
|
|
||||||
|
client = Cloudflare(api_token=os.environ.get("CLOUDFLARE_API_TOKEN"))
|
||||||
|
|
||||||
|
# Enable Cache Reserve
|
||||||
|
client.cache.cache_reserve.edit(
|
||||||
|
zone_id="abc123",
|
||||||
|
value="on"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get Cache Reserve status
|
||||||
|
status = client.cache.cache_reserve.get(zone_id="abc123")
|
||||||
|
print(status.value) # 'on' or 'off'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terraform
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
cloudflare = {
|
||||||
|
source = "cloudflare/cloudflare"
|
||||||
|
version = "~> 4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "cloudflare" {
|
||||||
|
api_token = var.cloudflare_api_token
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "cloudflare_zone_cache_reserve" "example" {
|
||||||
|
zone_id = var.zone_id
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tiered Cache is required for Cache Reserve
|
||||||
|
resource "cloudflare_tiered_cache" "example" {
|
||||||
|
zone_id = var.zone_id
|
||||||
|
cache_type = "smart"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pulumi
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as cloudflare from "@pulumi/cloudflare";
|
||||||
|
|
||||||
|
// Enable Cache Reserve
|
||||||
|
const cacheReserve = new cloudflare.ZoneCacheReserve("example", {
|
||||||
|
zoneId: zoneId,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable Tiered Cache (required)
|
||||||
|
const tieredCache = new cloudflare.TieredCache("example", {
|
||||||
|
zoneId: zoneId,
|
||||||
|
cacheType: "smart",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required API Token Permissions
|
||||||
|
|
||||||
|
- `Zone Settings Read`
|
||||||
|
- `Zone Settings Write`
|
||||||
|
- `Zone Read`
|
||||||
|
- `Zone Write`
|
||||||
|
|
||||||
|
## Cache Rules Integration
|
||||||
|
|
||||||
|
Control Cache Reserve eligibility via Cache Rules:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Enable for static assets
|
||||||
|
{
|
||||||
|
action: 'set_cache_settings',
|
||||||
|
action_parameters: {
|
||||||
|
cache_reserve: { eligible: true, minimum_file_ttl: 86400 },
|
||||||
|
edge_ttl: { mode: 'override_origin', default: 86400 },
|
||||||
|
cache: true
|
||||||
|
},
|
||||||
|
expression: '(http.request.uri.path matches "\\.(jpg|png|webp|pdf|zip)$")'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable for APIs
|
||||||
|
{
|
||||||
|
action: 'set_cache_settings',
|
||||||
|
action_parameters: { cache_reserve: { eligible: false } },
|
||||||
|
expression: '(http.request.uri.path matches "^/api/")'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create via API: PUT to zones/{zone_id}/rulesets/phases/http_request_cache_settings/entrypoint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wrangler Integration
|
||||||
|
|
||||||
|
Cache Reserve works automatically with Workers deployed via Wrangler. No special wrangler.jsonc configuration needed - enable Cache Reserve via Dashboard or API for the zone.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [README](./README.md) - Overview and core concepts
|
||||||
|
- [API Reference](./api.md) - Purging and monitoring APIs
|
||||||
|
- [Patterns](./patterns.md) - Best practices and optimization
|
||||||
|
- [Gotchas](./gotchas.md) - Common issues and troubleshooting
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Cache Reserve Gotchas
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### "Assets Not Being Cached in Cache Reserve"
|
||||||
|
|
||||||
|
**Cause:** Asset is not cacheable, TTL < 10 hours, Content-Length header missing, or blocking headers present (Set-Cookie, Vary: *)
|
||||||
|
**Solution:** Ensure minimum TTL of 10+ hours (`Cache-Control: public, max-age=36000`), add Content-Length header, remove Set-Cookie header, and set `Vary: Accept-Encoding` (not *)
|
||||||
|
|
||||||
|
### "Range Requests Not Working" (Video Seeking Fails)
|
||||||
|
|
||||||
|
**Cause:** Cache Reserve does **NOT** support range requests (HTTP 206 Partial Content)
|
||||||
|
**Solution:** Range requests bypass Cache Reserve entirely. For video streaming with seeking:
|
||||||
|
- Use edge cache only (shorter TTLs)
|
||||||
|
- Consider R2 with direct access for range-heavy workloads
|
||||||
|
- Accept that seekable content won't benefit from Cache Reserve persistence
|
||||||
|
|
||||||
|
### "Origin Bandwidth Higher Than Expected"
|
||||||
|
|
||||||
|
**Cause:** Cache Reserve fetches **uncompressed** content from origin, even though it serves compressed to visitors
|
||||||
|
**Solution:**
|
||||||
|
- If origin charges by bandwidth, factor in uncompressed transfer costs
|
||||||
|
- Cache Reserve compresses for visitors automatically (saves visitor bandwidth)
|
||||||
|
- Compare: origin egress savings vs higher uncompressed fetch costs
|
||||||
|
|
||||||
|
### "Cloudflare Images Not Caching with Cache Reserve"
|
||||||
|
|
||||||
|
**Cause:** Cloudflare Images with `Vary: Accept` header (format negotiation) is incompatible with Cache Reserve
|
||||||
|
**Solution:**
|
||||||
|
- Cache Reserve silently skips images with Vary for format negotiation
|
||||||
|
- Original images (non-transformed) may still be eligible
|
||||||
|
- Use Cloudflare Images variants or edge cache for transformed images
|
||||||
|
|
||||||
|
### "High Class A Operations Costs"
|
||||||
|
|
||||||
|
**Cause:** Frequent cache misses, short TTLs, or frequent revalidation
|
||||||
|
**Solution:** Increase TTL for stable content (24+ hours), enable Tiered Cache to reduce direct Cache Reserve misses, or use stale-while-revalidate
|
||||||
|
|
||||||
|
### "Purge Not Working as Expected"
|
||||||
|
|
||||||
|
**Cause:** Purge by tag only triggers revalidation but doesn't remove from Cache Reserve storage
|
||||||
|
**Solution:** Use purge by URL for immediate removal, or disable Cache Reserve then clear all data for complete removal
|
||||||
|
|
||||||
|
### "O2O (Orange-to-Orange) Assets Not Caching"
|
||||||
|
|
||||||
|
**Cause:** Orange-to-Orange (proxied zone requesting another proxied zone on Cloudflare) bypasses Cache Reserve
|
||||||
|
**Solution:**
|
||||||
|
- **What is O2O**: Zone A (proxied) → Zone B (proxied), both on Cloudflare
|
||||||
|
- **Detection**: Check `cf-cache-status` for `BYPASS` and review request path
|
||||||
|
- **Workaround**: Use R2 or direct origin access instead of O2O proxy chains
|
||||||
|
|
||||||
|
### "Cache Reserve must be OFF before clearing data"
|
||||||
|
|
||||||
|
**Cause:** Attempting to clear Cache Reserve data while it's still enabled
|
||||||
|
**Solution:** Disable Cache Reserve first, wait briefly for propagation (5s), then clear data (can take up to 24 hours)
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
| Limit | Value | Notes |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| Minimum TTL | 10 hours (36000 seconds) | Assets with shorter TTL not eligible |
|
||||||
|
| Default retention | 30 days (2592000 seconds) | Configurable |
|
||||||
|
| Maximum file size | Same as R2 limits | No practical limit |
|
||||||
|
| Purge/clear time | Up to 24 hours | Complete propagation time |
|
||||||
|
| Plan requirement | Paid Cache Reserve or Smart Shield | Not available on free plans |
|
||||||
|
| Content-Length header | Required | Must be present for eligibility |
|
||||||
|
| Set-Cookie header | Blocks caching | Must not be present (or use private directive) |
|
||||||
|
| Vary header | Cannot be * | Can use Vary: Accept-Encoding |
|
||||||
|
| Image transformations | Variants not eligible | Original images only |
|
||||||
|
| Range requests | NOT supported | HTTP 206 bypasses Cache Reserve |
|
||||||
|
| Compression | Fetches uncompressed | Serves compressed to visitors |
|
||||||
|
| Worker control | Zone-level only | Cannot control per-request |
|
||||||
|
| O2O requests | Bypassed | Orange-to-Orange not eligible |
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- **Official Docs**: https://developers.cloudflare.com/cache/advanced-configuration/cache-reserve/
|
||||||
|
- **API Reference**: https://developers.cloudflare.com/api/resources/cache/subresources/cache_reserve/
|
||||||
|
- **Cache Rules**: https://developers.cloudflare.com/cache/how-to/cache-rules/
|
||||||
|
- **Workers Cache API**: https://developers.cloudflare.com/workers/runtime-apis/cache/
|
||||||
|
- **R2 Documentation**: https://developers.cloudflare.com/r2/
|
||||||
|
- **Smart Shield**: https://developers.cloudflare.com/smart-shield/
|
||||||
|
- **Tiered Cache**: https://developers.cloudflare.com/cache/how-to/tiered-cache/
|
||||||
|
|
||||||
|
## Troubleshooting Flowchart
|
||||||
|
|
||||||
|
Asset not caching in Cache Reserve?
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Is Cache Reserve enabled for zone?
|
||||||
|
→ No: Enable via Dashboard or API
|
||||||
|
→ Yes: Continue to step 2
|
||||||
|
|
||||||
|
2. Is Tiered Cache enabled?
|
||||||
|
→ No: Enable Tiered Cache (required!)
|
||||||
|
→ Yes: Continue to step 3
|
||||||
|
|
||||||
|
3. Does asset have TTL ≥ 10 hours?
|
||||||
|
→ No: Increase via Cache Rules (edge_ttl override)
|
||||||
|
→ Yes: Continue to step 4
|
||||||
|
|
||||||
|
4. Is Content-Length header present?
|
||||||
|
→ No: Fix origin to include Content-Length
|
||||||
|
→ Yes: Continue to step 5
|
||||||
|
|
||||||
|
5. Is Set-Cookie header present?
|
||||||
|
→ Yes: Remove Set-Cookie or scope appropriately
|
||||||
|
→ No: Continue to step 6
|
||||||
|
|
||||||
|
6. Is Vary header set to *?
|
||||||
|
→ Yes: Change to specific value (e.g., Accept-Encoding)
|
||||||
|
→ No: Continue to step 7
|
||||||
|
|
||||||
|
7. Is this a range request?
|
||||||
|
→ Yes: Range requests bypass Cache Reserve (not supported)
|
||||||
|
→ No: Continue to step 8
|
||||||
|
|
||||||
|
8. Is this an O2O (Orange-to-Orange) request?
|
||||||
|
→ Yes: O2O bypasses Cache Reserve
|
||||||
|
→ No: Continue to step 9
|
||||||
|
|
||||||
|
9. Check Logpush CacheReserveUsed field
|
||||||
|
→ Filter logs to see if assets ever hit Cache Reserve
|
||||||
|
→ Verify cf-cache-status header (should be HIT after first request)
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [README](./README.md) - Overview and core concepts
|
||||||
|
- [Configuration](./configuration.md) - Setup and Cache Rules
|
||||||
|
- [API Reference](./api.md) - Purging and monitoring
|
||||||
|
- [Patterns](./patterns.md) - Best practices and optimization
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Cache Reserve Patterns
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Always Enable Tiered Cache
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Cache Reserve is designed for use WITH Tiered Cache
|
||||||
|
const configuration = {
|
||||||
|
tieredCache: 'enabled', // Required for optimal performance
|
||||||
|
cacheReserve: 'enabled', // Works best with Tiered Cache
|
||||||
|
|
||||||
|
hierarchy: [
|
||||||
|
'Lower-Tier Cache (visitor)',
|
||||||
|
'Upper-Tier Cache (origin region)',
|
||||||
|
'Cache Reserve (persistent)',
|
||||||
|
'Origin'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set Appropriate Cache-Control Headers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Origin response headers for Cache Reserve eligibility
|
||||||
|
const originHeaders = {
|
||||||
|
'Cache-Control': 'public, max-age=86400', // 24hr (minimum 10hr)
|
||||||
|
'Content-Length': '1024000', // Required
|
||||||
|
'Cache-Tag': 'images,product-123', // Optional: purging
|
||||||
|
'ETag': '"abc123"', // Optional: revalidation
|
||||||
|
// Avoid: 'Set-Cookie' and 'Vary: *' prevent caching
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use Cache Rules for Fine-Grained Control
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Different TTLs for different content types
|
||||||
|
const cacheRules = [
|
||||||
|
{
|
||||||
|
description: 'Long-term cache for immutable assets',
|
||||||
|
expression: '(http.request.uri.path matches "^/static/.*\\.[a-f0-9]{8}\\.")',
|
||||||
|
action_parameters: {
|
||||||
|
cache_reserve: { eligible: true },
|
||||||
|
edge_ttl: { mode: 'override_origin', default: 2592000 }, // 30 days
|
||||||
|
cache: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Moderate cache for regular images',
|
||||||
|
expression: '(http.request.uri.path matches "\\.(jpg|png|webp)$")',
|
||||||
|
action_parameters: {
|
||||||
|
cache_reserve: { eligible: true },
|
||||||
|
edge_ttl: { mode: 'override_origin', default: 86400 }, // 24 hours
|
||||||
|
cache: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Exclude API from Cache Reserve',
|
||||||
|
expression: '(http.request.uri.path matches "^/api/")',
|
||||||
|
action_parameters: { cache_reserve: { eligible: false }, cache: false }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Making Assets Cache Reserve Eligible from Workers
|
||||||
|
|
||||||
|
**Note**: This modifies response headers to meet eligibility criteria but does NOT directly control Cache Reserve storage (which is zone-level automatic).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (!response.ok) return response;
|
||||||
|
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.set('Cache-Control', 'public, max-age=36000'); // 10hr minimum
|
||||||
|
headers.delete('Set-Cookie'); // Blocks caching
|
||||||
|
|
||||||
|
// Ensure Content-Length present
|
||||||
|
if (!headers.has('Content-Length')) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
headers.set('Content-Length', blob.size.toString());
|
||||||
|
return new Response(blob, { status: response.status, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(response.body, { status: response.status, headers });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Hostname Best Practices
|
||||||
|
|
||||||
|
Use Worker's hostname for efficient caching - avoid overriding hostname unnecessarily.
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Multi-Tier Caching + Immutable Assets
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Optimal: L1 (visitor) → L2 (region) → L3 (Cache Reserve) → Origin
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const isImmutable = /\.[a-f0-9]{8,}\.(js|css|jpg|png|woff2)$/.test(url.pathname);
|
||||||
|
const response = await fetch(request);
|
||||||
|
|
||||||
|
if (isImmutable) {
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
return new Response(response.body, { status: response.status, headers });
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cost Optimization
|
||||||
|
|
||||||
|
### Cost Calculator
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CacheReserveEstimate {
|
||||||
|
avgAssetSizeGB: number;
|
||||||
|
uniqueAssets: number;
|
||||||
|
monthlyReads: number;
|
||||||
|
monthlyWrites: number;
|
||||||
|
originEgressCostPerGB: number; // e.g., AWS: $0.09/GB
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateMonthlyCost(input: CacheReserveEstimate) {
|
||||||
|
// Cache Reserve pricing
|
||||||
|
const storageCostPerGBMonth = 0.015;
|
||||||
|
const classAPerMillion = 4.50; // writes
|
||||||
|
const classBPerMillion = 0.36; // reads
|
||||||
|
|
||||||
|
// Calculate Cache Reserve costs
|
||||||
|
const totalStorageGB = input.avgAssetSizeGB * input.uniqueAssets;
|
||||||
|
const storageCost = totalStorageGB * storageCostPerGBMonth;
|
||||||
|
const writeCost = (input.monthlyWrites / 1_000_000) * classAPerMillion;
|
||||||
|
const readCost = (input.monthlyReads / 1_000_000) * classBPerMillion;
|
||||||
|
|
||||||
|
const cacheReserveCost = storageCost + writeCost + readCost;
|
||||||
|
|
||||||
|
// Calculate origin egress cost (what you'd pay without Cache Reserve)
|
||||||
|
const totalTrafficGB = (input.monthlyReads * input.avgAssetSizeGB);
|
||||||
|
const originEgressCost = totalTrafficGB * input.originEgressCostPerGB;
|
||||||
|
|
||||||
|
// Savings calculation
|
||||||
|
const savings = originEgressCost - cacheReserveCost;
|
||||||
|
const savingsPercent = ((savings / originEgressCost) * 100).toFixed(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheReserveCost: `$${cacheReserveCost.toFixed(2)}`,
|
||||||
|
originEgressCost: `$${originEgressCost.toFixed(2)}`,
|
||||||
|
monthlySavings: `$${savings.toFixed(2)}`,
|
||||||
|
savingsPercent: `${savingsPercent}%`,
|
||||||
|
breakdown: {
|
||||||
|
storage: `$${storageCost.toFixed(2)}`,
|
||||||
|
writes: `$${writeCost.toFixed(2)}`,
|
||||||
|
reads: `$${readCost.toFixed(2)}`,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: Media library
|
||||||
|
const mediaLibrary = estimateMonthlyCost({
|
||||||
|
avgAssetSizeGB: 0.005, // 5MB images
|
||||||
|
uniqueAssets: 10_000,
|
||||||
|
monthlyReads: 5_000_000,
|
||||||
|
monthlyWrites: 50_000,
|
||||||
|
originEgressCostPerGB: 0.09, // AWS S3
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(mediaLibrary);
|
||||||
|
// {
|
||||||
|
// cacheReserveCost: "$9.98",
|
||||||
|
// originEgressCost: "$25.00",
|
||||||
|
// monthlySavings: "$15.02",
|
||||||
|
// savingsPercent: "60.1%",
|
||||||
|
// breakdown: { storage: "$0.75", writes: "$0.23", reads: "$9.00" }
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optimization Guidelines
|
||||||
|
|
||||||
|
- **Set appropriate TTLs**: 10hr minimum, 24hr+ optimal for stable content, 30d max cautiously
|
||||||
|
- **Cache high-value stable assets**: Images, media, fonts, archives, documentation
|
||||||
|
- **Exclude frequently changing**: APIs, user-specific content, real-time data
|
||||||
|
- **Compression note**: Cache Reserve fetches uncompressed from origin, serves compressed to visitors - factor in origin egress costs
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [README](./README.md) - Overview and core concepts
|
||||||
|
- [Configuration](./configuration.md) - Setup and Cache Rules
|
||||||
|
- [API Reference](./api.md) - Purging and monitoring
|
||||||
|
- [Gotchas](./gotchas.md) - Common issues and troubleshooting
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Cloudflare Containers Skill Reference
|
||||||
|
|
||||||
|
**APPLIES TO: Cloudflare Containers ONLY - NOT general Cloudflare Workers**
|
||||||
|
|
||||||
|
Use when working with Cloudflare Containers: deploying containerized apps on Workers platform, configuring container-enabled Durable Objects, managing container lifecycle, or implementing stateful/stateless container patterns.
|
||||||
|
|
||||||
|
## Beta Status
|
||||||
|
|
||||||
|
⚠️ Containers is currently in **beta**. API may change without notice. No SLA guarantees. Custom instance types added Jan 2026.
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
**Container as Durable Object:** Each container is a Durable Object with persistent identity. Accessed via `getByName(id)` or `getRandom()`.
|
||||||
|
|
||||||
|
**Image deployment:** Images pre-fetched globally. Deployments use rolling strategy (not instant like Workers).
|
||||||
|
|
||||||
|
**Lifecycle:** cold start (2-3s) → running → `sleepAfter` timeout → stopped. No autoscaling - manual load balancing via `getRandom()`.
|
||||||
|
|
||||||
|
**Persistent identity, ephemeral disk:** Container ID persists, but disk resets on stop. Use Durable Object storage for persistence.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Container } from "@cloudflare/containers";
|
||||||
|
|
||||||
|
export class MyContainer extends Container {
|
||||||
|
defaultPort = 8080;
|
||||||
|
sleepAfter = "30m";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
const container = env.MY_CONTAINER.getByName("instance-1");
|
||||||
|
await container.startAndWaitForPorts();
|
||||||
|
return container.fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
| Task | Files |
|
||||||
|
|------|-------|
|
||||||
|
| Setup new container project | README → configuration.md |
|
||||||
|
| Implement container logic | README → api.md → patterns.md |
|
||||||
|
| Choose routing pattern | patterns.md (routing section) |
|
||||||
|
| Debug issues | gotchas.md |
|
||||||
|
| Production hardening | gotchas.md → patterns.md (lifecycle) |
|
||||||
|
|
||||||
|
## Routing Decision Tree
|
||||||
|
|
||||||
|
**How should requests reach containers?**
|
||||||
|
|
||||||
|
- **Same user/session → same container:** Use `getByName(sessionId)` for session affinity
|
||||||
|
- **Stateless, spread load:** Use `getRandom()` for load balancing
|
||||||
|
- **Job per container:** Use `getByName(jobId)` + explicit lifecycle management
|
||||||
|
- **Single global instance:** Use `getByName("singleton")`
|
||||||
|
|
||||||
|
## When to Use Containers vs Workers
|
||||||
|
|
||||||
|
**Use Containers when:**
|
||||||
|
- Need stateful, long-lived processes (sessions, WebSockets, games)
|
||||||
|
- Running existing containerized apps (Node.js, Python, custom binaries)
|
||||||
|
- Need filesystem access or specific system dependencies
|
||||||
|
- Per-user/session isolation with dedicated compute
|
||||||
|
|
||||||
|
**Use Workers when:**
|
||||||
|
- Stateless HTTP handlers
|
||||||
|
- Sub-millisecond cold starts required
|
||||||
|
- Auto-scaling to zero critical
|
||||||
|
- Simple request/response patterns
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
- **[configuration.md](configuration.md)** - Wrangler config, instance types, Container class properties, environment variables, account limits
|
||||||
|
- **[api.md](api.md)** - Container class API, startup methods, communication (HTTP/TCP/WebSocket), routing helpers, lifecycle hooks, scheduling, state inspection
|
||||||
|
- **[patterns.md](patterns.md)** - Routing patterns (session affinity, load balancing, singleton), WebSocket forwarding, graceful shutdown, Workflow/Queue integration
|
||||||
|
- **[gotchas.md](gotchas.md)** - Critical gotchas (WebSocket, startup methods), common errors with solutions, specific limits, beta caveats
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Durable Objects](../durable-objects/) - Containers extend Durable Objects
|
||||||
|
- [Workflows](../workflows/) - Orchestrate container operations
|
||||||
|
- [Queues](../queues/) - Trigger containers from queue messages
|
||||||
|
- [Cloudflare Docs](https://developers.cloudflare.com/containers/)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
## Container Class API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Container } from "@cloudflare/containers";
|
||||||
|
|
||||||
|
export class MyContainer extends Container {
|
||||||
|
defaultPort = 8080;
|
||||||
|
requiredPorts = [8080];
|
||||||
|
sleepAfter = "30m";
|
||||||
|
enableInternet = true;
|
||||||
|
pingEndpoint = "/health";
|
||||||
|
envVars = {};
|
||||||
|
entrypoint = [];
|
||||||
|
|
||||||
|
onStart() { /* container started */ }
|
||||||
|
onStop() { /* container stopping */ }
|
||||||
|
onError(error: Error) { /* container error */ }
|
||||||
|
onActivityExpired(): boolean { /* timeout, return true to stay alive */ }
|
||||||
|
async alarm() { /* scheduled task */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
**getByName(id)** - Named instance for session affinity, per-user state
|
||||||
|
**getRandom()** - Random instance for load balancing stateless services
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const container = env.MY_CONTAINER.getByName("user-123");
|
||||||
|
const container = env.MY_CONTAINER.getRandom();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Startup Methods
|
||||||
|
|
||||||
|
### start() - Basic start (8s timeout)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await container.start();
|
||||||
|
await container.start({ envVars: { KEY: "value" } });
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns when **process starts**, NOT when ports ready. Use for fire-and-forget.
|
||||||
|
|
||||||
|
### startAndWaitForPorts() - Recommended (20s timeout)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await container.startAndWaitForPorts(); // Uses requiredPorts
|
||||||
|
await container.startAndWaitForPorts({ ports: [8080, 9090] });
|
||||||
|
await container.startAndWaitForPorts({
|
||||||
|
ports: [8080],
|
||||||
|
startOptions: { envVars: { KEY: "value" } }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns when **ports listening**. Use before HTTP/TCP requests.
|
||||||
|
|
||||||
|
**Port resolution:** explicit ports → requiredPorts → defaultPort → port 33
|
||||||
|
|
||||||
|
### waitForPort() - Wait for specific port
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await container.waitForPort(8080);
|
||||||
|
await container.waitForPort(8080, { timeout: 30000 });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
### fetch() - HTTP with WebSocket support
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Supports WebSocket upgrades
|
||||||
|
const response = await container.fetch(request);
|
||||||
|
const response = await container.fetch("http://container/api", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ data: "value" })
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use for:** All HTTP, especially WebSocket.
|
||||||
|
|
||||||
|
### containerFetch() - HTTP only (no WebSocket)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ No WebSocket support
|
||||||
|
const response = await container.containerFetch(request);
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Critical:** Use `fetch()` for WebSocket, not `containerFetch()`.
|
||||||
|
|
||||||
|
### TCP Connections
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const port = this.ctx.container.getTcpPort(8080);
|
||||||
|
const conn = port.connect();
|
||||||
|
await conn.opened;
|
||||||
|
|
||||||
|
if (request.body) await request.body.pipeTo(conn.writable);
|
||||||
|
return new Response(conn.readable);
|
||||||
|
```
|
||||||
|
|
||||||
|
### switchPort() - Change default port
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.switchPort(8081); // Subsequent fetch() uses this port
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lifecycle Hooks
|
||||||
|
|
||||||
|
### onStart()
|
||||||
|
|
||||||
|
Called when container process starts (ports may not be ready). Runs in `blockConcurrencyWhile` - no concurrent requests.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
onStart() {
|
||||||
|
console.log("Container starting");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### onStop()
|
||||||
|
|
||||||
|
Called when SIGTERM received. 15 minutes until SIGKILL. Use for graceful shutdown.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
onStop() {
|
||||||
|
// Save state, close connections, flush logs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### onError()
|
||||||
|
|
||||||
|
Called when container crashes or fails to start.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
onError(error: Error) {
|
||||||
|
console.error("Container error:", error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### onActivityExpired()
|
||||||
|
|
||||||
|
Called when `sleepAfter` timeout reached. Return `true` to stay alive, `false` to stop.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
onActivityExpired(): boolean {
|
||||||
|
if (this.hasActiveConnections()) return true; // Keep alive
|
||||||
|
return false; // OK to stop
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scheduling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ScheduledContainer extends Container {
|
||||||
|
async fetch(request: Request) {
|
||||||
|
await this.schedule(Date.now() + 60000); // 1 minute
|
||||||
|
await this.schedule("2026-01-28T00:00:00Z"); // ISO string
|
||||||
|
return new Response("Scheduled");
|
||||||
|
}
|
||||||
|
|
||||||
|
async alarm() {
|
||||||
|
// Called when schedule fires (SQLite-backed, survives restarts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Don't override `alarm()` directly when using `schedule()` helper.**
|
||||||
|
|
||||||
|
## State Inspection
|
||||||
|
|
||||||
|
### External state check
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const state = await container.getState();
|
||||||
|
// state.status: "starting" | "running" | "stopping" | "stopped"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Internal state check
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class MyContainer extends Container {
|
||||||
|
async fetch(request: Request) {
|
||||||
|
if (this.ctx.container.running) { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Use `getState()` for external checks, `ctx.container.running` for internal.**
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
## Wrangler Configuration
|
||||||
|
|
||||||
|
### Basic Container Config
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "my-worker",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2026-01-10",
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"class_name": "MyContainer",
|
||||||
|
"image": "./Dockerfile", // Path to Dockerfile or directory with Dockerfile
|
||||||
|
"instance_type": "standard-1", // Predefined or custom (see below)
|
||||||
|
"max_instances": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "MY_CONTAINER",
|
||||||
|
"class_name": "MyContainer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"tag": "v1",
|
||||||
|
"new_sqlite_classes": ["MyContainer"] // Must use new_sqlite_classes
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key config requirements:
|
||||||
|
- `image` - Path to Dockerfile or directory containing Dockerfile
|
||||||
|
- `class_name` - Must match Container class export name
|
||||||
|
- `max_instances` - Max concurrent container instances
|
||||||
|
- Must configure Durable Objects binding AND migrations
|
||||||
|
|
||||||
|
### Instance Types
|
||||||
|
|
||||||
|
#### Predefined Types
|
||||||
|
|
||||||
|
| Type | vCPU | Memory | Disk |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| lite | 1/16 | 256 MiB | 2 GB |
|
||||||
|
| basic | 1/4 | 1 GiB | 4 GB |
|
||||||
|
| standard-1 | 1/2 | 4 GiB | 8 GB |
|
||||||
|
| standard-2 | 1 | 6 GiB | 12 GB |
|
||||||
|
| standard-3 | 2 | 8 GiB | 16 GB |
|
||||||
|
| standard-4 | 4 | 12 GiB | 20 GB |
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"class_name": "MyContainer",
|
||||||
|
"image": "./Dockerfile",
|
||||||
|
"instance_type": "standard-2" // Use predefined type
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Custom Types (Jan 2026 Feature)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"class_name": "MyContainer",
|
||||||
|
"image": "./Dockerfile",
|
||||||
|
"instance_type_custom": {
|
||||||
|
"vcpu": 2, // 1-4 vCPU
|
||||||
|
"memory_mib": 8192, // 512-12288 MiB (up to 12 GiB)
|
||||||
|
"disk_mib": 16384 // 2048-20480 MiB (up to 20 GB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom type constraints:**
|
||||||
|
- Minimum 3 GiB memory per vCPU
|
||||||
|
- Maximum 2 GB disk per 1 GiB memory
|
||||||
|
- Max 4 vCPU, 12 GiB memory, 20 GB disk per container
|
||||||
|
|
||||||
|
### Account Limits
|
||||||
|
|
||||||
|
| Resource | Limit | Notes |
|
||||||
|
|----------|-------|-------|
|
||||||
|
| Total memory (all containers) | 400 GiB | Across all running containers |
|
||||||
|
| Total vCPU (all containers) | 100 | Across all running containers |
|
||||||
|
| Total disk (all containers) | 2 TB | Across all running containers |
|
||||||
|
| Image storage per account | 50 GB | Stored container images |
|
||||||
|
|
||||||
|
### Container Class Properties
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Container } from "@cloudflare/containers";
|
||||||
|
|
||||||
|
export class MyContainer extends Container {
|
||||||
|
// Port Configuration
|
||||||
|
defaultPort = 8080; // Default port for fetch() calls
|
||||||
|
requiredPorts = [8080, 9090]; // Ports to wait for in startAndWaitForPorts()
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
sleepAfter = "30m"; // Inactivity timeout (5m, 30m, 2h, etc.)
|
||||||
|
|
||||||
|
// Network
|
||||||
|
enableInternet = true; // Allow outbound internet access
|
||||||
|
|
||||||
|
// Health Check
|
||||||
|
pingEndpoint = "/health"; // Health check endpoint path
|
||||||
|
|
||||||
|
// Environment
|
||||||
|
envVars = { // Environment variables passed to container
|
||||||
|
NODE_ENV: "production",
|
||||||
|
LOG_LEVEL: "info"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Startup
|
||||||
|
entrypoint = ["/bin/start.sh"]; // Override image entrypoint (optional)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Property details:**
|
||||||
|
|
||||||
|
- **`defaultPort`**: Port used when calling `container.fetch()` without explicit port. Falls back to port 33 if not set.
|
||||||
|
|
||||||
|
- **`requiredPorts`**: Array of ports that must be listening before `startAndWaitForPorts()` returns. First port becomes default if `defaultPort` not set.
|
||||||
|
|
||||||
|
- **`sleepAfter`**: Duration string (e.g., "5m", "30m", "2h"). Container stops after this period of inactivity. Timer resets on each request.
|
||||||
|
|
||||||
|
- **`enableInternet`**: Boolean. If `true`, container can make outbound HTTP/TCP requests.
|
||||||
|
|
||||||
|
- **`pingEndpoint`**: Path used for health checks. Should respond with 2xx status.
|
||||||
|
|
||||||
|
- **`envVars`**: Object of environment variables. Merged with runtime-provided vars (see below).
|
||||||
|
|
||||||
|
- **`entrypoint`**: Array of strings. Overrides container image's CMD/ENTRYPOINT.
|
||||||
|
|
||||||
|
### Runtime Environment Variables
|
||||||
|
|
||||||
|
Cloudflare automatically provides these environment variables to containers:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `CLOUDFLARE_APPLICATION_ID` | Worker application ID |
|
||||||
|
| `CLOUDFLARE_COUNTRY_A2` | Two-letter country code of request origin |
|
||||||
|
| `CLOUDFLARE_LOCATION` | Cloudflare data center location |
|
||||||
|
| `CLOUDFLARE_REGION` | Region identifier |
|
||||||
|
| `CLOUDFLARE_DURABLE_OBJECT_ID` | Container's Durable Object ID |
|
||||||
|
|
||||||
|
Custom `envVars` from Container class are merged with these. Custom vars override runtime vars if names conflict.
|
||||||
|
|
||||||
|
### Image Management
|
||||||
|
|
||||||
|
**Distribution model:** Images pre-fetched to all global locations before deployment. Ensures fast cold starts (2-3s typical).
|
||||||
|
|
||||||
|
**Rolling deploys:** Unlike Workers (instant), container deployments roll out gradually. Old versions continue running during rollout.
|
||||||
|
|
||||||
|
**Ephemeral disk:** Container disk is ephemeral and resets on each stop. Use Durable Object storage (`this.ctx.storage`) for persistence.
|
||||||
|
|
||||||
|
## wrangler.toml Format
|
||||||
|
|
||||||
|
```toml
|
||||||
|
name = "my-worker"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2026-01-10"
|
||||||
|
|
||||||
|
[[containers]]
|
||||||
|
class_name = "MyContainer"
|
||||||
|
image = "./Dockerfile"
|
||||||
|
instance_type = "standard-2"
|
||||||
|
max_instances = 10
|
||||||
|
|
||||||
|
[[durable_objects.bindings]]
|
||||||
|
name = "MY_CONTAINER"
|
||||||
|
class_name = "MyContainer"
|
||||||
|
|
||||||
|
[[migrations]]
|
||||||
|
tag = "v1"
|
||||||
|
new_sqlite_classes = ["MyContainer"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `wrangler.jsonc` and `wrangler.toml` are supported. Use `wrangler.jsonc` for comments and better IDE support.
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
## Critical Gotchas
|
||||||
|
|
||||||
|
### ⚠️ WebSocket: fetch() vs containerFetch()
|
||||||
|
|
||||||
|
**Problem:** WebSocket connections fail silently
|
||||||
|
|
||||||
|
**Cause:** `containerFetch()` doesn't support WebSocket upgrades
|
||||||
|
|
||||||
|
**Fix:** Always use `fetch()` for WebSocket
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG
|
||||||
|
return container.containerFetch(request);
|
||||||
|
|
||||||
|
// ✅ CORRECT
|
||||||
|
return container.fetch(request);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ startAndWaitForPorts() vs start()
|
||||||
|
|
||||||
|
**Problem:** "connection refused" after `start()`
|
||||||
|
|
||||||
|
**Cause:** `start()` returns when process starts, NOT when ports ready
|
||||||
|
|
||||||
|
**Fix:** Use `startAndWaitForPorts()` before requests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG
|
||||||
|
await container.start();
|
||||||
|
return container.fetch(request);
|
||||||
|
|
||||||
|
// ✅ CORRECT
|
||||||
|
await container.startAndWaitForPorts();
|
||||||
|
return container.fetch(request);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ Activity Timeout on Long Operations
|
||||||
|
|
||||||
|
**Problem:** Container stops during long work
|
||||||
|
|
||||||
|
**Cause:** `sleepAfter` based on request activity, not internal work
|
||||||
|
|
||||||
|
**Fix:** Renew timeout by touching storage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
this.ctx.storage.put("keepalive", Date.now());
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.doLongWork(data);
|
||||||
|
} finally {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ blockConcurrencyWhile for Startup
|
||||||
|
|
||||||
|
**Problem:** Race conditions during initialization
|
||||||
|
|
||||||
|
**Fix:** Use `blockConcurrencyWhile` for atomic initialization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await this.ctx.blockConcurrencyWhile(async () => {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.startAndWaitForPorts();
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ Lifecycle Hooks Block Requests
|
||||||
|
|
||||||
|
**Problem:** Container unresponsive during `onStart()`
|
||||||
|
|
||||||
|
**Cause:** Hooks run in `blockConcurrencyWhile` - no concurrent requests
|
||||||
|
|
||||||
|
**Fix:** Keep hooks fast, avoid long operations
|
||||||
|
|
||||||
|
### ⚠️ Don't Override alarm() When Using schedule()
|
||||||
|
|
||||||
|
**Problem:** Scheduled tasks don't execute
|
||||||
|
|
||||||
|
**Cause:** `schedule()` uses `alarm()` internally
|
||||||
|
|
||||||
|
**Fix:** Implement `alarm()` to handle scheduled tasks
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### "Container start timeout"
|
||||||
|
|
||||||
|
**Cause:** Container took >8s (`start()`) or >20s (`startAndWaitForPorts()`)
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Optimize image (smaller base, fewer layers)
|
||||||
|
- Check `entrypoint` correct
|
||||||
|
- Verify app listens on correct ports
|
||||||
|
- Increase timeout if needed
|
||||||
|
|
||||||
|
### "Port not available"
|
||||||
|
|
||||||
|
**Cause:** Calling `fetch()` before port ready
|
||||||
|
|
||||||
|
**Solution:** Use `startAndWaitForPorts()`
|
||||||
|
|
||||||
|
### "Container memory exceeded"
|
||||||
|
|
||||||
|
**Cause:** Using more memory than instance type allows
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Use larger instance type (standard-2, standard-3, standard-4)
|
||||||
|
- Optimize app memory usage
|
||||||
|
- Use custom instance type
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"instance_type_custom": {
|
||||||
|
"vcpu": 2,
|
||||||
|
"memory_mib": 8192
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Max instances reached"
|
||||||
|
|
||||||
|
**Cause:** All `max_instances` slots in use
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Increase `max_instances`
|
||||||
|
- Implement proper `sleepAfter`
|
||||||
|
- Use `getRandom()` for distribution
|
||||||
|
- Check for instance leaks
|
||||||
|
|
||||||
|
### "No container instance available"
|
||||||
|
|
||||||
|
**Cause:** Account capacity limits reached
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Check account limits
|
||||||
|
- Review instance types across containers
|
||||||
|
- Contact Cloudflare support
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
| Resource | Limit | Notes |
|
||||||
|
|----------|-------|-------|
|
||||||
|
| Cold start | 2-3s | Image pre-fetched globally |
|
||||||
|
| Graceful shutdown | 15 min | SIGTERM → SIGKILL |
|
||||||
|
| `start()` timeout | 8s | Process start |
|
||||||
|
| `startAndWaitForPorts()` timeout | 20s | Port ready |
|
||||||
|
| Max vCPU per container | 4 | standard-4 or custom |
|
||||||
|
| Max memory per container | 12 GiB | standard-4 or custom |
|
||||||
|
| Max disk per container | 20 GB | Ephemeral, resets |
|
||||||
|
| Account total memory | 400 GiB | All containers |
|
||||||
|
| Account total vCPU | 100 | All containers |
|
||||||
|
| Account total disk | 2 TB | All containers |
|
||||||
|
| Image storage | 50 GB | Per account |
|
||||||
|
| Disk persistence | None | Use DO storage |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use `startAndWaitForPorts()` by default** - Prevents port errors
|
||||||
|
2. **Set appropriate `sleepAfter`** - Balance resources vs cold starts
|
||||||
|
3. **Use `fetch()` for WebSocket** - Not `containerFetch()`
|
||||||
|
4. **Design for restarts** - Ephemeral disk, implement graceful shutdown
|
||||||
|
5. **Monitor resources** - Stay within account limits
|
||||||
|
6. **Keep hooks fast** - Run in `blockConcurrencyWhile`
|
||||||
|
7. **Renew activity for long ops** - Touch storage to prevent timeout
|
||||||
|
|
||||||
|
## Beta Caveats
|
||||||
|
|
||||||
|
⚠️ Containers in **beta**:
|
||||||
|
|
||||||
|
- **API may change** without notice
|
||||||
|
- **No SLA** guarantees
|
||||||
|
- **Limited regions** initially
|
||||||
|
- **No autoscaling** - manual via `getRandom()`
|
||||||
|
- **Rolling deploys** only (not instant like Workers)
|
||||||
|
|
||||||
|
Plan for API changes, test thoroughly before production.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
## Routing Patterns
|
||||||
|
|
||||||
|
### Session Affinity (Stateful)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class SessionBackend extends Container {
|
||||||
|
defaultPort = 3000;
|
||||||
|
sleepAfter = "30m";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
const sessionId = request.headers.get("X-Session-ID") || crypto.randomUUID();
|
||||||
|
const container = env.SESSION_BACKEND.getByName(sessionId);
|
||||||
|
await container.startAndWaitForPorts();
|
||||||
|
return container.fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use:** User sessions, WebSocket, stateful games, per-user caching.
|
||||||
|
|
||||||
|
### Load Balancing (Stateless)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
const container = env.STATELESS_API.getRandom();
|
||||||
|
await container.startAndWaitForPorts();
|
||||||
|
return container.fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use:** Stateless HTTP APIs, CPU-intensive work, read-only queries.
|
||||||
|
|
||||||
|
### Singleton Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
const container = env.GLOBAL_SERVICE.getByName("singleton");
|
||||||
|
await container.startAndWaitForPorts();
|
||||||
|
return container.fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use:** Global cache, centralized coordinator, single source of truth.
|
||||||
|
|
||||||
|
## WebSocket Forwarding
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
if (request.headers.get("Upgrade") === "websocket") {
|
||||||
|
const sessionId = request.headers.get("X-Session-ID") || crypto.randomUUID();
|
||||||
|
const container = env.WS_BACKEND.getByName(sessionId);
|
||||||
|
await container.startAndWaitForPorts();
|
||||||
|
|
||||||
|
// ⚠️ MUST use fetch(), not containerFetch()
|
||||||
|
return container.fetch(request);
|
||||||
|
}
|
||||||
|
return new Response("Not a WebSocket request", { status: 400 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Critical:** Always use `fetch()` for WebSocket.
|
||||||
|
|
||||||
|
## Graceful Shutdown
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class GracefulContainer extends Container {
|
||||||
|
private connections = new Set<WebSocket>();
|
||||||
|
|
||||||
|
onStop() {
|
||||||
|
// SIGTERM received, 15 minutes until SIGKILL
|
||||||
|
for (const ws of this.connections) {
|
||||||
|
ws.close(1001, "Server shutting down");
|
||||||
|
}
|
||||||
|
this.ctx.storage.put("shutdown-time", Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
onActivityExpired(): boolean {
|
||||||
|
return this.connections.size > 0; // Keep alive if connections
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Concurrent Request Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class SafeContainer extends Container {
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
async fetch(request: Request) {
|
||||||
|
await this.ctx.blockConcurrencyWhile(async () => {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.startAndWaitForPorts();
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return super.fetch(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use:** One-time initialization, preventing concurrent startup.
|
||||||
|
|
||||||
|
## Activity Timeout Renewal
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class LongRunningContainer extends Container {
|
||||||
|
sleepAfter = "5m";
|
||||||
|
|
||||||
|
async processLongJob(data: unknown) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
this.ctx.storage.put("keepalive", Date.now());
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.doLongWork(data);
|
||||||
|
} finally {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use:** Long operations exceeding `sleepAfter`.
|
||||||
|
|
||||||
|
## Multiple Port Routing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class MultiPortContainer extends Container {
|
||||||
|
requiredPorts = [8080, 8081, 9090];
|
||||||
|
|
||||||
|
async fetch(request: Request) {
|
||||||
|
const path = new URL(request.url).pathname;
|
||||||
|
if (path.startsWith("/grpc")) this.switchPort(8081);
|
||||||
|
else if (path.startsWith("/metrics")) this.switchPort(9090);
|
||||||
|
return super.fetch(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use:** Multi-protocol services (HTTP + gRPC), separate metrics endpoints.
|
||||||
|
|
||||||
|
## Workflow Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { WorkflowEntrypoint } from "cloudflare:workers";
|
||||||
|
|
||||||
|
export class ProcessingWorkflow extends WorkflowEntrypoint {
|
||||||
|
async run(event, step) {
|
||||||
|
const container = this.env.PROCESSOR.getByName(event.payload.jobId);
|
||||||
|
|
||||||
|
await step.do("start", async () => {
|
||||||
|
await container.startAndWaitForPorts();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await step.do("process", async () => {
|
||||||
|
return container.fetch("/process", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(event.payload.data)
|
||||||
|
}).then(r => r.json());
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use:** Orchestrating multi-step container operations, durable execution.
|
||||||
|
|
||||||
|
## Queue Consumer Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async queue(batch, env) {
|
||||||
|
for (const msg of batch.messages) {
|
||||||
|
try {
|
||||||
|
const container = env.PROCESSOR.getByName(msg.body.jobId);
|
||||||
|
await container.startAndWaitForPorts();
|
||||||
|
|
||||||
|
const response = await container.fetch("/process", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(msg.body)
|
||||||
|
});
|
||||||
|
|
||||||
|
response.ok ? msg.ack() : msg.retry();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Queue processing error:", err);
|
||||||
|
msg.retry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use:** Asynchronous job processing, batch operations, event-driven execution.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Cloudflare Cron Triggers
|
||||||
|
|
||||||
|
Schedule Workers execution using cron expressions. Runs on Cloudflare's global network during underutilized periods.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **UTC-only execution** - All schedules run on UTC time
|
||||||
|
- **5-field cron syntax** - Quartz scheduler extensions (L, W, #)
|
||||||
|
- **Global propagation** - 15min deployment delay
|
||||||
|
- **At-least-once delivery** - Rare duplicate executions possible
|
||||||
|
- **Workflow integration** - Trigger long-running multi-step tasks
|
||||||
|
- **Green Compute** - Optional carbon-aware scheduling during low-carbon periods
|
||||||
|
|
||||||
|
## Cron Syntax
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────── minute (0-59)
|
||||||
|
│ ┌───────── hour (0-23)
|
||||||
|
│ │ ┌─────── day of month (1-31)
|
||||||
|
│ │ │ ┌───── month (1-12, JAN-DEC)
|
||||||
|
│ │ │ │ ┌─── day of week (1-7, SUN-SAT, 1=Sunday)
|
||||||
|
* * * * *
|
||||||
|
```
|
||||||
|
|
||||||
|
**Special chars:** `*` (any), `,` (list), `-` (range), `/` (step), `L` (last), `W` (weekday), `#` (nth)
|
||||||
|
|
||||||
|
## Common Schedules
|
||||||
|
|
||||||
|
```bash
|
||||||
|
*/5 * * * * # Every 5 minutes
|
||||||
|
0 * * * * # Hourly
|
||||||
|
0 2 * * * # Daily 2am UTC (off-peak)
|
||||||
|
0 9 * * MON-FRI # Weekdays 9am UTC
|
||||||
|
0 0 1 * * # Monthly 1st midnight UTC
|
||||||
|
0 9 L * * # Last day of month 9am UTC
|
||||||
|
0 10 * * MON#2 # 2nd Monday 10am UTC
|
||||||
|
*/10 9-17 * * MON-FRI # Every 10min, 9am-5pm weekdays
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
**wrangler.jsonc:**
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "my-cron-worker",
|
||||||
|
"triggers": {
|
||||||
|
"crons": ["*/5 * * * *", "0 2 * * *"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Handler:**
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(
|
||||||
|
controller: ScheduledController,
|
||||||
|
env: Env,
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
): Promise<void> {
|
||||||
|
console.log("Cron:", controller.cron);
|
||||||
|
console.log("Time:", new Date(controller.scheduledTime));
|
||||||
|
|
||||||
|
ctx.waitUntil(asyncTask(env)); // Non-blocking
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test locally:**
|
||||||
|
```bash
|
||||||
|
npx wrangler dev
|
||||||
|
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- **Free:** 3 triggers/worker, 10ms CPU
|
||||||
|
- **Paid:** Unlimited triggers, 50ms CPU
|
||||||
|
- **Propagation:** 15min global deployment
|
||||||
|
- **Timezone:** UTC only
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
**New to cron triggers?** Start here:
|
||||||
|
1. This README - Overview and quick start
|
||||||
|
2. [configuration.md](./configuration.md) - Set up your first cron trigger
|
||||||
|
3. [api.md](./api.md) - Understand the handler API
|
||||||
|
4. [patterns.md](./patterns.md) - Common use cases and examples
|
||||||
|
|
||||||
|
**Troubleshooting?** Jump to [gotchas.md](./gotchas.md)
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
- [configuration.md](./configuration.md) - wrangler config, env-specific schedules, Green Compute
|
||||||
|
- [api.md](./api.md) - ScheduledController, noRetry(), waitUntil, testing patterns
|
||||||
|
- [patterns.md](./patterns.md) - Use cases, monitoring, queue integration, Durable Objects
|
||||||
|
- [gotchas.md](./gotchas.md) - Timezone issues, idempotency, security, testing
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
- [workflows](../workflows/) - Alternative for long-running scheduled tasks
|
||||||
|
- [workers](../workers/) - Worker runtime documentation
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Cron Triggers API
|
||||||
|
|
||||||
|
## Basic Handler
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
console.log("Cron executed:", new Date(controller.scheduledTime));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**JavaScript:** Same signature without types
|
||||||
|
**Python:** `class Default(WorkerEntrypoint): async def scheduled(self, controller, env, ctx)`
|
||||||
|
|
||||||
|
## ScheduledController
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ScheduledController {
|
||||||
|
scheduledTime: number; // Unix ms when scheduled to run
|
||||||
|
cron: string; // Expression that triggered (e.g., "*/5 * * * *")
|
||||||
|
type: string; // Always "scheduled"
|
||||||
|
noRetry(): void; // Prevent automatic retry on failure
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prevent retry on failure:**
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
try {
|
||||||
|
await riskyOperation(env);
|
||||||
|
} catch (error) {
|
||||||
|
// Don't retry - failure is expected/acceptable
|
||||||
|
controller.noRetry();
|
||||||
|
console.error("Operation failed, not retrying:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use noRetry():**
|
||||||
|
- External API failures outside your control (avoid hammering failed services)
|
||||||
|
- Rate limit errors (retry would fail again immediately)
|
||||||
|
- Duplicate execution detected (idempotency check failed)
|
||||||
|
- Non-critical operations where skip is acceptable (analytics, caching)
|
||||||
|
- Validation errors that won't resolve on retry
|
||||||
|
|
||||||
|
## Handler Parameters
|
||||||
|
|
||||||
|
**`controller: ScheduledController`**
|
||||||
|
- Access cron expression and scheduled time
|
||||||
|
|
||||||
|
**`env: Env`**
|
||||||
|
- All bindings: KV, R2, D1, secrets, service bindings
|
||||||
|
|
||||||
|
**`ctx: ExecutionContext`**
|
||||||
|
- `ctx.waitUntil(promise)` - Extend execution for async tasks (logging, cleanup, external APIs)
|
||||||
|
- First `waitUntil` failure recorded in Cron Events
|
||||||
|
|
||||||
|
## Multiple Schedules
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
switch (controller.cron) {
|
||||||
|
case "*/3 * * * *": ctx.waitUntil(updateRecentData(env)); break;
|
||||||
|
case "0 * * * *": ctx.waitUntil(processHourlyAggregation(env)); break;
|
||||||
|
case "0 2 * * *": ctx.waitUntil(performDailyMaintenance(env)); break;
|
||||||
|
default: console.warn(`Unhandled: ${controller.cron}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## ctx.waitUntil Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
const data = await fetchCriticalData(); // Critical path
|
||||||
|
|
||||||
|
// Non-blocking background tasks
|
||||||
|
ctx.waitUntil(Promise.all([
|
||||||
|
logToAnalytics(data),
|
||||||
|
cleanupOldRecords(env.DB),
|
||||||
|
notifyWebhook(env.WEBHOOK_URL, data),
|
||||||
|
]));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { WorkflowEntrypoint } from "cloudflare:workers";
|
||||||
|
|
||||||
|
export class DataProcessingWorkflow extends WorkflowEntrypoint {
|
||||||
|
async run(event, step) {
|
||||||
|
const data = await step.do("fetch-data", () => fetchLargeDataset());
|
||||||
|
const processed = await step.do("process-data", () => processDataset(data));
|
||||||
|
await step.do("store-results", () => storeResults(processed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
const instance = await env.MY_WORKFLOW.create({
|
||||||
|
params: { scheduledTime: controller.scheduledTime, cron: controller.cron },
|
||||||
|
});
|
||||||
|
console.log(`Started workflow: ${instance.id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Handler
|
||||||
|
|
||||||
|
**Local development (/__scheduled endpoint):**
|
||||||
|
```bash
|
||||||
|
# Start dev server
|
||||||
|
npx wrangler dev
|
||||||
|
|
||||||
|
# Trigger any cron
|
||||||
|
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*"
|
||||||
|
|
||||||
|
# Trigger specific cron with custom time
|
||||||
|
curl "http://localhost:8787/__scheduled?cron=0+2+*+*+*&scheduledTime=1704067200000"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query parameters:**
|
||||||
|
- `cron` - Required. URL-encoded cron expression (use `+` for spaces)
|
||||||
|
- `scheduledTime` - Optional. Unix timestamp in milliseconds (defaults to current time)
|
||||||
|
|
||||||
|
**Production security:** The `/__scheduled` endpoint is available in production and can be triggered by anyone. Block it or implement authentication - see [gotchas.md](./gotchas.md#security-concerns)
|
||||||
|
|
||||||
|
**Unit testing (Vitest):**
|
||||||
|
```typescript
|
||||||
|
// test/scheduled.test.ts
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { env } from "cloudflare:test";
|
||||||
|
import worker from "../src/index";
|
||||||
|
|
||||||
|
describe("Scheduled Handler", () => {
|
||||||
|
it("processes scheduled event", async () => {
|
||||||
|
const controller = { scheduledTime: Date.now(), cron: "*/5 * * * *", type: "scheduled" as const, noRetry: () => {} };
|
||||||
|
const ctx = { waitUntil: (p: Promise<any>) => p, passThroughOnException: () => {} };
|
||||||
|
await worker.scheduled(controller, env, ctx);
|
||||||
|
expect(await env.MY_KV.get("last_run")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple crons", async () => {
|
||||||
|
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} };
|
||||||
|
await worker.scheduled({ scheduledTime: Date.now(), cron: "*/5 * * * *", type: "scheduled", noRetry: () => {} }, env, ctx);
|
||||||
|
expect(await env.MY_KV.get("last_type")).toBe("frequent");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Automatic retries:**
|
||||||
|
- Failed cron executions are retried automatically unless `noRetry()` is called
|
||||||
|
- Retry happens after a delay (typically minutes)
|
||||||
|
- Only first `waitUntil()` failure is recorded in Cron Events
|
||||||
|
|
||||||
|
**Best practices:**
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
try {
|
||||||
|
await criticalOperation(env);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error details
|
||||||
|
console.error("Cron failed:", {
|
||||||
|
cron: controller.cron,
|
||||||
|
scheduledTime: controller.scheduledTime,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decide: retry or skip
|
||||||
|
if (error.message.includes("rate limit")) {
|
||||||
|
controller.noRetry(); // Skip retry for rate limits
|
||||||
|
}
|
||||||
|
// Otherwise allow automatic retry
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [README.md](./README.md) - Overview
|
||||||
|
- [patterns.md](./patterns.md) - Use cases, examples
|
||||||
|
- [gotchas.md](./gotchas.md) - Common errors, testing issues
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Cron Triggers Configuration
|
||||||
|
|
||||||
|
## wrangler.jsonc
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "my-cron-worker",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2025-01-01", // Use current date for new projects
|
||||||
|
|
||||||
|
"triggers": {
|
||||||
|
"crons": [
|
||||||
|
"*/5 * * * *", // Every 5 minutes
|
||||||
|
"0 */2 * * *", // Every 2 hours
|
||||||
|
"0 9 * * MON-FRI", // Weekdays at 9am UTC
|
||||||
|
"0 2 1 * *" // Monthly on 1st at 2am UTC
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Green Compute (Beta)
|
||||||
|
|
||||||
|
Schedule crons during low-carbon periods for carbon-aware execution:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "eco-cron-worker",
|
||||||
|
"triggers": {
|
||||||
|
"crons": ["0 2 * * *"]
|
||||||
|
},
|
||||||
|
"placement": {
|
||||||
|
"mode": "smart" // Runs during low-carbon periods
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modes:**
|
||||||
|
- `"smart"` - Carbon-aware scheduling (may delay up to 24h for optimal window)
|
||||||
|
- Default (no placement config) - Standard scheduling (no delay)
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- Cloudflare delays execution until grid carbon intensity is lower
|
||||||
|
- Maximum delay: 24 hours from scheduled time
|
||||||
|
- Ideal for batch jobs with flexible timing requirements
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Nightly data processing and ETL pipelines
|
||||||
|
- Weekly/monthly report generation
|
||||||
|
- Database backups and maintenance
|
||||||
|
- Analytics aggregation
|
||||||
|
- ML model training
|
||||||
|
|
||||||
|
**Not suitable for:**
|
||||||
|
- Time-sensitive operations (SLA requirements)
|
||||||
|
- User-facing features requiring immediate execution
|
||||||
|
- Real-time monitoring and alerting
|
||||||
|
- Compliance tasks with strict time windows
|
||||||
|
|
||||||
|
## Environment-Specific Schedules
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "my-cron-worker",
|
||||||
|
"triggers": {
|
||||||
|
"crons": ["0 */6 * * *"] // Prod: every 6 hours
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"staging": {
|
||||||
|
"triggers": {
|
||||||
|
"crons": ["*/15 * * * *"] // Staging: every 15min
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"triggers": {
|
||||||
|
"crons": ["*/5 * * * *"] // Dev: every 5min
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schedule Format
|
||||||
|
|
||||||
|
**Structure:** `minute hour day-of-month month day-of-week`
|
||||||
|
|
||||||
|
**Special chars:** `*` (any), `,` (list), `-` (range), `/` (step), `L` (last), `W` (weekday), `#` (nth)
|
||||||
|
|
||||||
|
## Managing Triggers
|
||||||
|
|
||||||
|
**Remove all:** `"triggers": { "crons": [] }`
|
||||||
|
**Preserve existing:** Omit `"triggers"` field entirely
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy with config crons
|
||||||
|
npx wrangler deploy
|
||||||
|
|
||||||
|
# Deploy specific environment
|
||||||
|
npx wrangler deploy --env production
|
||||||
|
|
||||||
|
# View deployments
|
||||||
|
npx wrangler deployments list
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Changes take up to 15 minutes to propagate globally**
|
||||||
|
|
||||||
|
## API Management
|
||||||
|
|
||||||
|
**Get triggers:**
|
||||||
|
```bash
|
||||||
|
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/scripts/{script_name}/schedules" \
|
||||||
|
-H "Authorization: Bearer {api_token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update triggers:**
|
||||||
|
```bash
|
||||||
|
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/scripts/{script_name}/schedules" \
|
||||||
|
-H "Authorization: Bearer {api_token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"crons": ["*/5 * * * *", "0 2 * * *"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delete all:**
|
||||||
|
```bash
|
||||||
|
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/scripts/{script_name}/schedules" \
|
||||||
|
-H "Authorization: Bearer {api_token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"crons": []}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Combining Multiple Workers
|
||||||
|
|
||||||
|
For complex schedules, use multiple workers:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// worker-frequent.jsonc
|
||||||
|
{
|
||||||
|
"name": "data-sync-frequent",
|
||||||
|
"triggers": { "crons": ["*/5 * * * *"] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker-daily.jsonc
|
||||||
|
{
|
||||||
|
"name": "reports-daily",
|
||||||
|
"triggers": { "crons": ["0 2 * * *"] },
|
||||||
|
"placement": { "mode": "smart" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker-weekly.jsonc
|
||||||
|
{
|
||||||
|
"name": "cleanup-weekly",
|
||||||
|
"triggers": { "crons": ["0 3 * * SUN"] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Separate CPU limits per worker
|
||||||
|
- Independent error isolation
|
||||||
|
- Different Green Compute policies
|
||||||
|
- Easier to maintain and debug
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
**Test cron syntax:**
|
||||||
|
- [crontab.guru](https://crontab.guru/) - Interactive validator
|
||||||
|
- Wrangler validates on deploy but won't catch logic errors
|
||||||
|
|
||||||
|
**Common mistakes:**
|
||||||
|
- `0 0 * * *` runs daily at midnight UTC, not your local timezone
|
||||||
|
- `*/60 * * * *` is invalid (use `0 * * * *` for hourly)
|
||||||
|
- `0 2 31 * *` only runs on months with 31 days
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [README.md](./README.md) - Overview, quick start
|
||||||
|
- [api.md](./api.md) - Handler implementation
|
||||||
|
- [patterns.md](./patterns.md) - Multi-cron routing examples
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# Cron Triggers Gotchas
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### "Timezone Issues"
|
||||||
|
|
||||||
|
**Problem:** Cron runs at wrong time relative to local timezone
|
||||||
|
**Cause:** All crons execute in UTC, no local timezone support
|
||||||
|
**Solution:** Convert local time to UTC manually
|
||||||
|
|
||||||
|
**Conversion formula:** `utcHour = (localHour - utcOffset + 24) % 24`
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- 9am PST (UTC-8) → `(9 - (-8) + 24) % 24 = 17` → `0 17 * * *`
|
||||||
|
- 2am EST (UTC-5) → `(2 - (-5) + 24) % 24 = 7` → `0 7 * * *`
|
||||||
|
- 6pm JST (UTC+9) → `(18 - 9 + 24) % 24 = 33 % 24 = 9` → `0 9 * * *`
|
||||||
|
|
||||||
|
**Daylight Saving Time:** Adjust manually when DST changes, or schedule at times unaffected by DST (e.g., 2am-4am local time usually safe)
|
||||||
|
|
||||||
|
### "Cron Not Executing"
|
||||||
|
|
||||||
|
**Cause:** Missing `scheduled()` export, invalid syntax, propagation delay (<15min), or plan limits
|
||||||
|
**Solution:** Verify export exists, validate at crontab.guru, wait 15+ min after deploy, check plan limits
|
||||||
|
|
||||||
|
### "Duplicate Executions"
|
||||||
|
|
||||||
|
**Cause:** At-least-once delivery
|
||||||
|
**Solution:** Track execution IDs in KV - see idempotency pattern below
|
||||||
|
|
||||||
|
### "Execution Failures"
|
||||||
|
|
||||||
|
**Cause:** CPU exceeded, unhandled exceptions, network timeouts, binding errors
|
||||||
|
**Solution:** Use try-catch, AbortController timeouts, `ctx.waitUntil()` for long ops, or Workflows for heavy tasks
|
||||||
|
|
||||||
|
### "Local Testing Not Working"
|
||||||
|
|
||||||
|
**Problem:** `/__scheduled` endpoint returns 404 or doesn't trigger handler
|
||||||
|
**Cause:** Missing `scheduled()` export, wrangler not running, or incorrect endpoint format
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
1. Verify `scheduled()` is exported:
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
console.log("Cron triggered");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start dev server:
|
||||||
|
```bash
|
||||||
|
npx wrangler dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Use correct endpoint format (URL-encode spaces as `+`):
|
||||||
|
```bash
|
||||||
|
# Correct
|
||||||
|
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*"
|
||||||
|
|
||||||
|
# Wrong (will fail)
|
||||||
|
curl "http://localhost:8787/__scheduled?cron=*/5 * * * *"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Update Wrangler if outdated:
|
||||||
|
```bash
|
||||||
|
npm install -g wrangler@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### "waitUntil() Tasks Not Completing"
|
||||||
|
|
||||||
|
**Problem:** Background tasks in `ctx.waitUntil()` fail silently or don't execute
|
||||||
|
**Cause:** Promises rejected without error handling, or handler returns before promise settles
|
||||||
|
**Solution:** Always await or handle errors in waitUntil promises:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
// BAD: Silent failures
|
||||||
|
ctx.waitUntil(riskyOperation());
|
||||||
|
|
||||||
|
// GOOD: Explicit error handling
|
||||||
|
ctx.waitUntil(
|
||||||
|
riskyOperation().catch(err => {
|
||||||
|
console.error("Background task failed:", err);
|
||||||
|
return logError(err, env);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Idempotency Issues"
|
||||||
|
|
||||||
|
**Problem:** At-least-once delivery causes duplicate side effects (double charges, duplicate emails)
|
||||||
|
**Cause:** No deduplication mechanism
|
||||||
|
**Solution:** Use KV to track execution IDs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
const executionId = `${controller.cron}-${controller.scheduledTime}`;
|
||||||
|
const existing = await env.EXECUTIONS.get(executionId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
console.log("Already executed, skipping");
|
||||||
|
controller.noRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await env.EXECUTIONS.put(executionId, "1", { expirationTtl: 86400 }); // 24h TTL
|
||||||
|
await performIdempotentOperation(env);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Security Concerns"
|
||||||
|
|
||||||
|
**Problem:** `__scheduled` endpoint exposed in production allows unauthorized cron triggering
|
||||||
|
**Cause:** Testing endpoint available in deployed Workers
|
||||||
|
**Solution:** Block `__scheduled` in production:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request, env, ctx) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Block __scheduled in production
|
||||||
|
if (url.pathname === "/__scheduled" && env.ENVIRONMENT === "production") {
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleRequest(request, env, ctx);
|
||||||
|
},
|
||||||
|
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
// Your cron logic
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Also:** Use `env.API_KEY` for secrets (never hardcode)
|
||||||
|
|
||||||
|
**Alternative:** Add middleware to verify request origin:
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async fetch(request, env, ctx) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/__scheduled") {
|
||||||
|
// Check Cloudflare headers to verify internal request
|
||||||
|
const cfRay = request.headers.get("cf-ray");
|
||||||
|
if (!cfRay && env.ENVIRONMENT === "production") {
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleRequest(request, env, ctx);
|
||||||
|
},
|
||||||
|
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
// Your cron logic
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limits & Quotas
|
||||||
|
|
||||||
|
| Limit | Free | Paid | Notes |
|
||||||
|
|-------|------|------|-------|
|
||||||
|
| Triggers per Worker | 3 | Unlimited | Maximum cron schedules per Worker |
|
||||||
|
| CPU time | 10ms | 50ms | May need `ctx.waitUntil()` or Workflows |
|
||||||
|
| Execution guarantee | At-least-once | At-least-once | Duplicates possible - use idempotency |
|
||||||
|
| Propagation delay | Up to 15 minutes | Up to 15 minutes | Time for changes to take effect globally |
|
||||||
|
| Min interval | 1 minute | 1 minute | Cannot schedule more frequently |
|
||||||
|
| Cron accuracy | ±1 minute | ±1 minute | Execution may drift slightly |
|
||||||
|
|
||||||
|
## Testing Best Practices
|
||||||
|
|
||||||
|
**Unit tests:**
|
||||||
|
- Mock `ScheduledController`, `ExecutionContext`, and bindings
|
||||||
|
- Test each cron expression separately
|
||||||
|
- Verify `noRetry()` is called when expected
|
||||||
|
- Use Vitest with `@cloudflare/vitest-pool-workers` for realistic env
|
||||||
|
|
||||||
|
**Integration tests:**
|
||||||
|
- Test via `/__scheduled` endpoint in dev environment
|
||||||
|
- Verify idempotency logic with duplicate `scheduledTime` values
|
||||||
|
- Test error handling and retry behavior
|
||||||
|
|
||||||
|
**Production:** Start with long intervals (`*/30 * * * *`), monitor Cron Events for 24h, set up alerts before reducing interval
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Cron Triggers Docs](https://developers.cloudflare.com/workers/configuration/cron-triggers/)
|
||||||
|
- [Scheduled Handler API](https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/)
|
||||||
|
- [Cloudflare Workflows](https://developers.cloudflare.com/workflows/)
|
||||||
|
- [Workers Limits](https://developers.cloudflare.com/workers/platform/limits/)
|
||||||
|
- [Crontab Guru](https://crontab.guru/) - Validator
|
||||||
|
- [Vitest Pool Workers](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples)
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Cron Triggers Patterns
|
||||||
|
|
||||||
|
## API Data Sync
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
const response = await fetch("https://api.example.com/data", {headers: { "Authorization": `Bearer ${env.API_KEY}` }});
|
||||||
|
if (!response.ok) throw new Error(`API error: ${response.status}`);
|
||||||
|
ctx.waitUntil(env.MY_KV.put("cached_data", JSON.stringify(await response.json()), {expirationTtl: 3600}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Cleanup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
const result = await env.DB.prepare(`DELETE FROM sessions WHERE expires_at < datetime('now')`).run();
|
||||||
|
console.log(`Deleted ${result.meta.changes} expired sessions`);
|
||||||
|
ctx.waitUntil(env.DB.prepare("VACUUM").run());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Report Generation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
const startOfWeek = new Date(); startOfWeek.setDate(startOfWeek.getDate() - 7);
|
||||||
|
const { results } = await env.DB.prepare(`SELECT date, revenue, orders FROM daily_stats WHERE date >= ? ORDER BY date`).bind(startOfWeek.toISOString()).all();
|
||||||
|
const report = {period: "weekly", totalRevenue: results.reduce((sum, d) => sum + d.revenue, 0), totalOrders: results.reduce((sum, d) => sum + d.orders, 0), dailyBreakdown: results};
|
||||||
|
const reportKey = `reports/weekly-${Date.now()}.json`;
|
||||||
|
await env.REPORTS_BUCKET.put(reportKey, JSON.stringify(report));
|
||||||
|
ctx.waitUntil(env.SEND_EMAIL.fetch("https://example.com/send", {method: "POST", body: JSON.stringify({to: "team@example.com", subject: "Weekly Report", reportUrl: `https://reports.example.com/${reportKey}`})}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Checks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
const services = [{name: "API", url: "https://api.example.com/health"}, {name: "CDN", url: "https://cdn.example.com/health"}];
|
||||||
|
const checks = await Promise.all(services.map(async (service) => {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const response = await fetch(service.url, { signal: AbortSignal.timeout(5000) });
|
||||||
|
return {name: service.name, status: response.ok ? "up" : "down", responseTime: Date.now() - start};
|
||||||
|
} catch (error) {
|
||||||
|
return {name: service.name, status: "down", responseTime: Date.now() - start, error: error.message};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
ctx.waitUntil(env.STATUS_KV.put("health_status", JSON.stringify(checks)));
|
||||||
|
const failures = checks.filter(c => c.status === "down");
|
||||||
|
if (failures.length > 0) ctx.waitUntil(fetch(env.ALERT_WEBHOOK, {method: "POST", body: JSON.stringify({text: `${failures.length} service(s) down: ${failures.map(f => f.name).join(", ")}`})}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Processing (Rate-Limited)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
const queueData = await env.QUEUE_KV.get("pending_items", "json");
|
||||||
|
if (!queueData || queueData.length === 0) return;
|
||||||
|
const batch = queueData.slice(0, 100);
|
||||||
|
const results = await Promise.allSettled(batch.map(item => fetch("https://api.example.com/process", {method: "POST", headers: {"Authorization": `Bearer ${env.API_KEY}`, "Content-Type": "application/json"}, body: JSON.stringify(item)})));
|
||||||
|
console.log(`Processed ${results.filter(r => r.status === "fulfilled").length}/${batch.length} items`);
|
||||||
|
ctx.waitUntil(env.QUEUE_KV.put("pending_items", JSON.stringify(queueData.slice(100))));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Queue Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
const batch = await env.MY_QUEUE.receive({ batchSize: 100 });
|
||||||
|
const results = await Promise.allSettled(batch.messages.map(async (msg) => {
|
||||||
|
await processMessage(msg.body, env);
|
||||||
|
await msg.ack();
|
||||||
|
}));
|
||||||
|
console.log(`Processed ${results.filter(r => r.status === "fulfilled").length}/${batch.messages.length}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const meta = { cron: controller.cron, scheduledTime: controller.scheduledTime };
|
||||||
|
console.log("[START]", meta);
|
||||||
|
try {
|
||||||
|
const result = await performTask(env);
|
||||||
|
console.log("[SUCCESS]", { ...meta, duration: Date.now() - startTime, count: result.count });
|
||||||
|
ctx.waitUntil(env.METRICS.put(`cron:${controller.scheduledTime}`, JSON.stringify({ ...meta, status: "success" }), { expirationTtl: 2592000 }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ERROR]", { ...meta, duration: Date.now() - startTime, error: error.message });
|
||||||
|
ctx.waitUntil(fetch(env.ALERT_WEBHOOK, { method: "POST", body: JSON.stringify({ text: `Cron failed: ${controller.cron}`, error: error.message }) }));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**View logs:** `npx wrangler tail` or Dashboard → Workers & Pages → Worker → Logs
|
||||||
|
|
||||||
|
## Durable Objects Coordination
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default {
|
||||||
|
async scheduled(controller, env, ctx) {
|
||||||
|
const stub = env.COORDINATOR.get(env.COORDINATOR.idFromName("cron-lock"));
|
||||||
|
const acquired = await stub.tryAcquireLock(controller.scheduledTime);
|
||||||
|
if (!acquired) {
|
||||||
|
controller.noRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await performTask(env);
|
||||||
|
} finally {
|
||||||
|
await stub.releaseLock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Handler
|
||||||
|
|
||||||
|
```python
|
||||||
|
from workers import WorkerEntrypoint
|
||||||
|
|
||||||
|
class Default(WorkerEntrypoint):
|
||||||
|
async def scheduled(self, controller, env, ctx):
|
||||||
|
data = await env.MY_KV.get("key")
|
||||||
|
ctx.waitUntil(env.DB.execute("DELETE FROM logs WHERE created_at < datetime('now', '-7 days')"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Patterns
|
||||||
|
|
||||||
|
**Local testing with /__scheduled:**
|
||||||
|
```bash
|
||||||
|
# Start dev server
|
||||||
|
npx wrangler dev
|
||||||
|
|
||||||
|
# Test specific cron
|
||||||
|
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*"
|
||||||
|
|
||||||
|
# Test with specific time
|
||||||
|
curl "http://localhost:8787/__scheduled?cron=0+2+*+*+*&scheduledTime=1704067200000"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unit tests:**
|
||||||
|
```typescript
|
||||||
|
// test/scheduled.test.ts
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { env } from "cloudflare:test";
|
||||||
|
import worker from "../src/index";
|
||||||
|
|
||||||
|
describe("Scheduled Handler", () => {
|
||||||
|
it("executes cron", async () => {
|
||||||
|
const controller = { scheduledTime: Date.now(), cron: "*/5 * * * *", type: "scheduled" as const, noRetry: vi.fn() };
|
||||||
|
const ctx = { waitUntil: vi.fn(), passThroughOnException: vi.fn() };
|
||||||
|
await worker.scheduled(controller, env, ctx);
|
||||||
|
expect(await env.MY_KV.get("last_run")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls noRetry on duplicate", async () => {
|
||||||
|
const controller = { scheduledTime: 1704067200000, cron: "0 2 * * *", type: "scheduled" as const, noRetry: vi.fn() };
|
||||||
|
await env.EXECUTIONS.put("0 2 * * *-1704067200000", "1");
|
||||||
|
await worker.scheduled(controller, env, { waitUntil: vi.fn(), passThroughOnException: vi.fn() });
|
||||||
|
expect(controller.noRetry).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [README.md](./README.md) - Overview
|
||||||
|
- [api.md](./api.md) - Handler implementation
|
||||||
|
- [gotchas.md](./gotchas.md) - Troubleshooting
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Cloudflare D1 Database
|
||||||
|
|
||||||
|
Expert guidance for Cloudflare D1, a serverless SQLite database designed for horizontal scale-out across multiple databases.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
D1 is Cloudflare's managed, serverless database with:
|
||||||
|
- SQLite SQL semantics and compatibility
|
||||||
|
- Built-in disaster recovery via Time Travel (30-day point-in-time recovery)
|
||||||
|
- Horizontal scale-out architecture (10 GB per database)
|
||||||
|
- Worker and HTTP API access
|
||||||
|
- Pricing based on query and storage costs only
|
||||||
|
|
||||||
|
**Architecture Philosophy**: D1 is optimized for per-user, per-tenant, or per-entity database patterns rather than single large databases.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create database
|
||||||
|
wrangler d1 create <database-name>
|
||||||
|
|
||||||
|
# Execute migration
|
||||||
|
wrangler d1 migrations apply <db-name> --remote
|
||||||
|
|
||||||
|
# Local development
|
||||||
|
wrangler dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Query Methods
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// .all() - Returns all rows; .first() - First row or null; .first(col) - Single column value
|
||||||
|
// .run() - INSERT/UPDATE/DELETE; .raw() - Array of arrays (efficient)
|
||||||
|
const { results, success, meta } = await env.DB.prepare('SELECT * FROM users WHERE active = ?').bind(true).all();
|
||||||
|
const user = await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId).first();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Multiple queries in single round trip (atomic transaction)
|
||||||
|
const results = await env.DB.batch([
|
||||||
|
env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(1),
|
||||||
|
env.DB.prepare('SELECT * FROM posts WHERE author_id = ?').bind(1),
|
||||||
|
env.DB.prepare('UPDATE users SET last_access = ? WHERE id = ?').bind(Date.now(), 1)
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sessions API (Paid Plans)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create long-running session for analytics/migrations (up to 15 minutes)
|
||||||
|
const session = env.DB.withSession();
|
||||||
|
try {
|
||||||
|
await session.prepare('CREATE INDEX idx_heavy ON large_table(column)').run();
|
||||||
|
await session.prepare('ANALYZE').run();
|
||||||
|
} finally {
|
||||||
|
session.close(); // Always close to release resources
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Read Replication (Paid Plans)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Read from nearest replica for lower latency (automatic failover)
|
||||||
|
const user = await env.DB_REPLICA.prepare('SELECT * FROM users WHERE id = ?').bind(userId).first();
|
||||||
|
|
||||||
|
// Writes always go to primary
|
||||||
|
await env.DB.prepare('UPDATE users SET last_login = ? WHERE id = ?').bind(Date.now(), userId).run();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Limits
|
||||||
|
|
||||||
|
| Limit | Free Tier | Paid Plans |
|
||||||
|
|-------|-----------|------------|
|
||||||
|
| Database size | 500 MB | 10 GB per database |
|
||||||
|
| Row size | 1 MB max | 1 MB max |
|
||||||
|
| Query timeout | 30 seconds | 30 seconds |
|
||||||
|
| Batch size | 1,000 statements | 10,000 statements |
|
||||||
|
| Time Travel retention | 7 days | 30 days |
|
||||||
|
| Read replicas | Not available | Yes (paid add-on) |
|
||||||
|
|
||||||
|
**Pricing**: $5/month per database beyond free tier + $0.001 per 1K reads + $1 per 1M writes + $0.75/GB storage/month
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database management
|
||||||
|
wrangler d1 create <db-name>
|
||||||
|
wrangler d1 list
|
||||||
|
wrangler d1 delete <db-name>
|
||||||
|
|
||||||
|
# Migrations
|
||||||
|
wrangler d1 migrations create <db-name> <migration-name> # Create new migration file
|
||||||
|
wrangler d1 migrations apply <db-name> --remote # Apply pending migrations
|
||||||
|
wrangler d1 migrations apply <db-name> --local # Apply locally
|
||||||
|
wrangler d1 migrations list <db-name> --remote # Show applied migrations
|
||||||
|
|
||||||
|
# Direct SQL execution
|
||||||
|
wrangler d1 execute <db-name> --remote --command="SELECT * FROM users"
|
||||||
|
wrangler d1 execute <db-name> --local --file=./schema.sql
|
||||||
|
|
||||||
|
# Backups & Import/Export
|
||||||
|
wrangler d1 export <db-name> --remote --output=./backup.sql # Full export with schema
|
||||||
|
wrangler d1 export <db-name> --remote --no-schema --output=./data.sql # Data only
|
||||||
|
wrangler d1 time-travel restore <db-name> --timestamp="2024-01-15T14:30:00Z" # Point-in-time recovery
|
||||||
|
|
||||||
|
# Development
|
||||||
|
wrangler dev --persist-to=./.wrangler/state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
**Start here**: Quick Start above → configuration.md (setup) → api.md (queries)
|
||||||
|
|
||||||
|
**Common tasks**:
|
||||||
|
- First time setup: configuration.md → Run migrations
|
||||||
|
- Adding queries: api.md → Prepared statements
|
||||||
|
- Pagination/caching: patterns.md
|
||||||
|
- Production optimization: Read Replication + Sessions API (this file)
|
||||||
|
- Debugging: gotchas.md
|
||||||
|
|
||||||
|
## In This Reference
|
||||||
|
|
||||||
|
- [configuration.md](./configuration.md) - wrangler.jsonc setup, migrations, TypeScript types, ORMs, local dev
|
||||||
|
- [api.md](./api.md) - Query methods (.all/.first/.run/.raw), batch, sessions, read replicas, error handling
|
||||||
|
- [patterns.md](./patterns.md) - Pagination, bulk operations, caching, multi-tenant, sessions, analytics
|
||||||
|
- [gotchas.md](./gotchas.md) - SQL injection, limits by plan tier, performance, common errors
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [workers](../workers/) - Worker runtime and fetch handler patterns
|
||||||
|
- [hyperdrive](../hyperdrive/) - Connection pooling for external databases
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# D1 API Reference
|
||||||
|
|
||||||
|
## Prepared Statements (Required for Security)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ NEVER: Direct string interpolation (SQL injection risk)
|
||||||
|
const result = await env.DB.prepare(`SELECT * FROM users WHERE id = ${userId}`).all();
|
||||||
|
|
||||||
|
// ✅ CORRECT: Prepared statements with bind()
|
||||||
|
const result = await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId).all();
|
||||||
|
|
||||||
|
// Multiple parameters
|
||||||
|
const result = await env.DB.prepare('SELECT * FROM users WHERE email = ? AND active = ?').bind(email, true).all();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Execution Methods
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// .all() - Returns all rows
|
||||||
|
const { results, success, meta } = await env.DB.prepare('SELECT * FROM users WHERE active = ?').bind(true).all();
|
||||||
|
// results: Array of row objects; success: boolean
|
||||||
|
// meta: { duration: number, rows_read: number, rows_written: number }
|
||||||
|
|
||||||
|
// .first() - Returns first row or null
|
||||||
|
const user = await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId).first();
|
||||||
|
|
||||||
|
// .first(columnName) - Returns single column value
|
||||||
|
const email = await env.DB.prepare('SELECT email FROM users WHERE id = ?').bind(userId).first('email');
|
||||||
|
// Returns string | number | null
|
||||||
|
|
||||||
|
// .run() - For INSERT/UPDATE/DELETE (no row data returned)
|
||||||
|
const result = await env.DB.prepare('UPDATE users SET last_login = ? WHERE id = ?').bind(Date.now(), userId).run();
|
||||||
|
// result.meta: { duration, rows_read, rows_written, last_row_id, changes }
|
||||||
|
|
||||||
|
// .raw() - Returns array of arrays (efficient for large datasets)
|
||||||
|
const rawResults = await env.DB.prepare('SELECT id, name FROM users').raw();
|
||||||
|
// [[1, 'Alice'], [2, 'Bob']]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Execute multiple queries in single round trip (atomic transaction)
|
||||||
|
const results = await env.DB.batch([
|
||||||
|
env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(1),
|
||||||
|
env.DB.prepare('SELECT * FROM posts WHERE author_id = ?').bind(1),
|
||||||
|
env.DB.prepare('UPDATE users SET last_access = ? WHERE id = ?').bind(Date.now(), 1)
|
||||||
|
]);
|
||||||
|
// results is array: [result1, result2, result3]
|
||||||
|
|
||||||
|
// Batch with same prepared statement, different params
|
||||||
|
const userIds = [1, 2, 3];
|
||||||
|
const stmt = env.DB.prepare('SELECT * FROM users WHERE id = ?');
|
||||||
|
const results = await env.DB.batch(userIds.map(id => stmt.bind(id)));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transactions (via batch)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// D1 executes batch() as atomic transaction - all succeed or all fail
|
||||||
|
const results = await env.DB.batch([
|
||||||
|
env.DB.prepare('INSERT INTO accounts (id, balance) VALUES (?, ?)').bind(1, 100),
|
||||||
|
env.DB.prepare('INSERT INTO accounts (id, balance) VALUES (?, ?)').bind(2, 200),
|
||||||
|
env.DB.prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?').bind(50, 1),
|
||||||
|
env.DB.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?').bind(50, 2)
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sessions API (Paid Plans)
|
||||||
|
|
||||||
|
Long-running sessions for operations exceeding 30s timeout (up to 15 min).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const session = env.DB.withSession({ timeout: 600 }); // 10 min (1-900s)
|
||||||
|
try {
|
||||||
|
await session.prepare('CREATE INDEX idx_large ON big_table(column)').run();
|
||||||
|
await session.prepare('ANALYZE').run();
|
||||||
|
} finally {
|
||||||
|
session.close(); // CRITICAL: always close to prevent leaks
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases**: Migrations, ANALYZE, large index creation, bulk transformations
|
||||||
|
|
||||||
|
## Read Replication (Paid Plans)
|
||||||
|
|
||||||
|
Routes queries to nearest replica for lower latency. Writes always go to primary.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Env {
|
||||||
|
DB: D1Database; // Primary (writes)
|
||||||
|
DB_REPLICA: D1Database; // Replica (reads)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads: use replica
|
||||||
|
const user = await env.DB_REPLICA.prepare('SELECT * FROM users WHERE id = ?').bind(userId).first();
|
||||||
|
|
||||||
|
// Writes: use primary
|
||||||
|
await env.DB.prepare('UPDATE users SET last_login = ? WHERE id = ?').bind(Date.now(), userId).run();
|
||||||
|
|
||||||
|
// Read-after-write: use primary for consistency (replication lag <100ms-2s)
|
||||||
|
await env.DB.prepare('INSERT INTO posts (title) VALUES (?)').bind(title).run();
|
||||||
|
const post = await env.DB.prepare('SELECT * FROM posts WHERE title = ?').bind(title).first(); // Primary
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function getUser(userId: number, env: Env): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const result = await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId).all();
|
||||||
|
if (!result.success) return new Response('Database error', { status: 500 });
|
||||||
|
if (result.results.length === 0) return new Response('User not found', { status: 404 });
|
||||||
|
return Response.json(result.results[0]);
|
||||||
|
} catch (error) {
|
||||||
|
return new Response('Internal error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constraint violations
|
||||||
|
try {
|
||||||
|
await env.DB.prepare('INSERT INTO users (email, name) VALUES (?, ?)').bind(email, name).run();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message?.includes('UNIQUE constraint failed')) return new Response('Email exists', { status: 409 });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## REST API (HTTP) Access
|
||||||
|
|
||||||
|
Access D1 from external services (non-Worker contexts) using Cloudflare API.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Single query
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/d1/database/${DATABASE_ID}/query`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sql: 'SELECT * FROM users WHERE id = ?',
|
||||||
|
params: [userId]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result, success, errors } = await response.json();
|
||||||
|
// result: [{ results: [...], success: true, meta: {...} }]
|
||||||
|
|
||||||
|
// Batch queries via HTTP
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/d1/database/${DATABASE_ID}/query`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify([
|
||||||
|
{ sql: 'SELECT * FROM users WHERE id = ?', params: [1] },
|
||||||
|
{ sql: 'SELECT * FROM posts WHERE author_id = ?', params: [1] }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases**: Server-side scripts, CI/CD migrations, administrative tools, non-Worker integrations
|
||||||
|
|
||||||
|
## Testing & Debugging
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Vitest with unstable_dev
|
||||||
|
import { unstable_dev } from 'wrangler';
|
||||||
|
describe('D1', () => {
|
||||||
|
let worker: Awaited<ReturnType<typeof unstable_dev>>;
|
||||||
|
beforeAll(async () => { worker = await unstable_dev('src/index.ts'); });
|
||||||
|
afterAll(async () => { await worker.stop(); });
|
||||||
|
it('queries users', async () => { expect((await worker.fetch('/users')).status).toBe(200); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug query performance
|
||||||
|
const result = await env.DB.prepare('SELECT * FROM users').all();
|
||||||
|
console.log('Duration:', result.meta.duration, 'ms');
|
||||||
|
|
||||||
|
// Query plan analysis
|
||||||
|
const plan = await env.DB.prepare('EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = ?').bind(email).all();
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inspect local database
|
||||||
|
sqlite3 .wrangler/state/v3/d1/<database-id>.sqlite
|
||||||
|
.tables; .schema users; PRAGMA table_info(users);
|
||||||
|
```
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# D1 Configuration
|
||||||
|
|
||||||
|
## wrangler.jsonc Setup
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "your-worker-name",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2025-01-01", // Use current date for new projects
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB", // Env variable name
|
||||||
|
"database_name": "your-db-name", // Human-readable name
|
||||||
|
"database_id": "your-database-id", // UUID from dashboard/CLI
|
||||||
|
"migrations_dir": "migrations" // Optional: default is "migrations"
|
||||||
|
},
|
||||||
|
// Read replica (paid plans only)
|
||||||
|
{
|
||||||
|
"binding": "DB_REPLICA",
|
||||||
|
"database_name": "your-db-name",
|
||||||
|
"database_id": "your-database-id" // Same ID, different binding
|
||||||
|
},
|
||||||
|
// Multiple databases
|
||||||
|
{
|
||||||
|
"binding": "ANALYTICS_DB",
|
||||||
|
"database_name": "analytics-db",
|
||||||
|
"database_id": "yyy-yyy-yyy"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Env { DB: D1Database; ANALYTICS_DB?: D1Database; }
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
const result = await env.DB.prepare('SELECT * FROM users').all();
|
||||||
|
return Response.json(result.results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
File structure: `migrations/0001_initial_schema.sql`, `0002_add_posts.sql`, etc.
|
||||||
|
|
||||||
|
### Example Migration
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- migrations/0001_initial_schema.sql
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
published BOOLEAN DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_posts_user_id ON posts(user_id);
|
||||||
|
CREATE INDEX idx_posts_published ON posts(published);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create new migration file
|
||||||
|
wrangler d1 migrations create <db-name> add_users_table
|
||||||
|
# Creates: migrations/0001_add_users_table.sql
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
wrangler d1 migrations apply <db-name> --local # Apply to local DB
|
||||||
|
wrangler d1 migrations apply <db-name> --remote # Apply to production DB
|
||||||
|
|
||||||
|
# List applied migrations
|
||||||
|
wrangler d1 migrations list <db-name> --remote
|
||||||
|
|
||||||
|
# Direct SQL execution (bypasses migration tracking)
|
||||||
|
wrangler d1 execute <db-name> --remote --command="SELECT * FROM users"
|
||||||
|
wrangler d1 execute <db-name> --local --file=./schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration tracking**: Wrangler creates `d1_migrations` table automatically to track applied migrations
|
||||||
|
|
||||||
|
## Indexing Strategy
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Index frequently queried columns
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
|
||||||
|
-- Composite indexes for multi-column queries
|
||||||
|
CREATE INDEX idx_posts_user_published ON posts(user_id, published);
|
||||||
|
|
||||||
|
-- Covering indexes (include queried columns)
|
||||||
|
CREATE INDEX idx_users_email_name ON users(email, name);
|
||||||
|
|
||||||
|
-- Partial indexes for filtered queries
|
||||||
|
CREATE INDEX idx_active_users ON users(email) WHERE active = 1;
|
||||||
|
|
||||||
|
-- Check if query uses index
|
||||||
|
EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = ?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Drizzle ORM
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// drizzle.config.ts
|
||||||
|
export default {
|
||||||
|
schema: './src/schema.ts', out: './migrations', dialect: 'sqlite', driver: 'd1-http',
|
||||||
|
dbCredentials: { accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, databaseId: process.env.D1_DATABASE_ID!, token: process.env.CLOUDFLARE_API_TOKEN! }
|
||||||
|
} satisfies Config;
|
||||||
|
|
||||||
|
// schema.ts
|
||||||
|
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||||
|
export const users = sqliteTable('users', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
name: text('name').notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// worker.ts
|
||||||
|
import { drizzle } from 'drizzle-orm/d1';
|
||||||
|
import { users } from './schema';
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
const db = drizzle(env.DB);
|
||||||
|
return Response.json(await db.select().from(users));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import & Export
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Export full database (schema + data)
|
||||||
|
wrangler d1 export <db-name> --remote --output=./backup.sql
|
||||||
|
|
||||||
|
# Export data only (no schema)
|
||||||
|
wrangler d1 export <db-name> --remote --no-schema --output=./data-only.sql
|
||||||
|
|
||||||
|
# Export with foreign key constraints preserved
|
||||||
|
# (Default: foreign keys are disabled during export for import compatibility)
|
||||||
|
|
||||||
|
# Import SQL file
|
||||||
|
wrangler d1 execute <db-name> --remote --file=./backup.sql
|
||||||
|
|
||||||
|
# Limitations
|
||||||
|
# - BLOB data may not export correctly (use R2 for binary files)
|
||||||
|
# - Very large exports (>1GB) may timeout (split into chunks)
|
||||||
|
# - Import is NOT atomic (use batch() for transactional imports in Workers)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plan Tiers
|
||||||
|
|
||||||
|
| Feature | Free | Paid |
|
||||||
|
|---------|------|------|
|
||||||
|
| Database size | 500 MB | 10 GB |
|
||||||
|
| Batch size | 1,000 statements | 10,000 statements |
|
||||||
|
| Time Travel | 7 days | 30 days |
|
||||||
|
| Read replicas | ❌ | ✅ |
|
||||||
|
| Sessions API | ❌ | ✅ (up to 15 min) |
|
||||||
|
| Pricing | Free | $5/mo + usage |
|
||||||
|
|
||||||
|
**Usage pricing** (paid plans): $0.001 per 1K reads + $1 per 1M writes + $0.75/GB storage/month
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler dev --persist-to=./.wrangler/state # Persist across restarts
|
||||||
|
# Local DB: .wrangler/state/v3/d1/<database-id>.sqlite
|
||||||
|
sqlite3 .wrangler/state/v3/d1/<database-id>.sqlite # Inspect
|
||||||
|
|
||||||
|
# Local dev uses free tier limits by default
|
||||||
|
```
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# D1 Gotchas & Troubleshooting
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### "SQL Injection Vulnerability"
|
||||||
|
|
||||||
|
**Cause:** Using string interpolation instead of prepared statements with bind()
|
||||||
|
**Solution:** ALWAYS use prepared statements: `env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId).all()` instead of string interpolation which allows attackers to inject malicious SQL
|
||||||
|
|
||||||
|
### "no such table"
|
||||||
|
|
||||||
|
**Cause:** Table doesn't exist because migrations haven't been run, or using wrong database binding
|
||||||
|
**Solution:** Run migrations using `wrangler d1 migrations apply <db-name> --remote` and verify binding name in wrangler.jsonc matches code
|
||||||
|
|
||||||
|
### "UNIQUE constraint failed"
|
||||||
|
|
||||||
|
**Cause:** Attempting to insert duplicate value in column with UNIQUE constraint
|
||||||
|
**Solution:** Catch error and return 409 Conflict status code
|
||||||
|
|
||||||
|
### "Query Timeout (30s exceeded)"
|
||||||
|
|
||||||
|
**Cause:** Query execution exceeds 30 second timeout limit
|
||||||
|
**Solution:** Break into smaller queries, add indexes to speed up queries, or reduce dataset size
|
||||||
|
|
||||||
|
### "N+1 Query Problem"
|
||||||
|
|
||||||
|
**Cause:** Making multiple individual queries in a loop instead of single optimized query
|
||||||
|
**Solution:** Use JOIN to fetch related data in single query or use `batch()` method for multiple queries
|
||||||
|
|
||||||
|
### "Missing Indexes"
|
||||||
|
|
||||||
|
**Cause:** Queries performing full table scans without indexes
|
||||||
|
**Solution:** Use `EXPLAIN QUERY PLAN` to check if index is used, then create index with `CREATE INDEX idx_users_email ON users(email)`
|
||||||
|
|
||||||
|
### "Boolean Type Issues"
|
||||||
|
|
||||||
|
**Cause:** SQLite uses INTEGER (0/1) not native boolean type
|
||||||
|
**Solution:** Bind 1 or 0 instead of true/false when working with boolean values
|
||||||
|
|
||||||
|
### "Date/Time Type Issues"
|
||||||
|
|
||||||
|
**Cause:** SQLite doesn't have native DATE/TIME types
|
||||||
|
**Solution:** Use TEXT (ISO 8601 format) or INTEGER (unix timestamp) for date/time values
|
||||||
|
|
||||||
|
## Plan Tier Limits
|
||||||
|
|
||||||
|
| Limit | Free Tier | Paid Plans | Notes |
|
||||||
|
|-------|-----------|------------|-------|
|
||||||
|
| Database size | 500 MB | 10 GB | Design for multiple DBs per tenant on paid |
|
||||||
|
| Row size | 1 MB | 1 MB | Store large files in R2, not D1 |
|
||||||
|
| Query timeout | 30s | 30s (900s with sessions) | Use sessions API for migrations |
|
||||||
|
| Batch size | 1,000 statements | 10,000 statements | Split large batches accordingly |
|
||||||
|
| Time Travel | 7 days | 30 days | Point-in-time recovery window |
|
||||||
|
| Read replicas | ❌ Not available | ✅ Available | Paid add-on for lower latency |
|
||||||
|
| Sessions API | ❌ Not available | ✅ Up to 15 min | For migrations and heavy operations |
|
||||||
|
| Concurrent requests | 10,000/min | Higher | Contact support for custom limits |
|
||||||
|
|
||||||
|
## Production Gotchas
|
||||||
|
|
||||||
|
### "Batch size exceeded"
|
||||||
|
|
||||||
|
**Cause:** Attempting to send >1,000 statements on free tier or >10,000 on paid
|
||||||
|
**Solution:** Chunk batches: `for (let i = 0; i < stmts.length; i += MAX_BATCH) await env.DB.batch(stmts.slice(i, i + MAX_BATCH))`
|
||||||
|
|
||||||
|
### "Session not closed / resource leak"
|
||||||
|
|
||||||
|
**Cause:** Forgot to call `session.close()` after using sessions API
|
||||||
|
**Solution:** Always use try/finally block: `try { await session.prepare(...) } finally { session.close() }`
|
||||||
|
|
||||||
|
### "Replication lag causing stale reads"
|
||||||
|
|
||||||
|
**Cause:** Reading from replica immediately after write - replication lag can be 100ms-2s
|
||||||
|
**Solution:** Use primary for read-after-write: `await env.DB.prepare(...)` not `env.DB_REPLICA`
|
||||||
|
|
||||||
|
### "Migration applied to local but not remote"
|
||||||
|
|
||||||
|
**Cause:** Forgot `--remote` flag when applying migrations
|
||||||
|
**Solution:** Always run `wrangler d1 migrations apply <db-name> --remote` for production
|
||||||
|
|
||||||
|
### "Foreign key constraint failed"
|
||||||
|
|
||||||
|
**Cause:** Inserting row with FK to non-existent parent, or deleting parent before children
|
||||||
|
**Solution:** Enable FK enforcement: `PRAGMA foreign_keys = ON;` and use ON DELETE CASCADE in schema
|
||||||
|
|
||||||
|
### "BLOB data corrupted on export"
|
||||||
|
|
||||||
|
**Cause:** D1 export may not handle BLOB correctly
|
||||||
|
**Solution:** Store binary files in R2, only store R2 URLs/keys in D1
|
||||||
|
|
||||||
|
### "Database size approaching limit"
|
||||||
|
|
||||||
|
**Cause:** Storing too much data in single database
|
||||||
|
**Solution:** Horizontal scale-out: create per-tenant/per-user databases, archive old data, or upgrade to paid plan
|
||||||
|
|
||||||
|
### "Local dev vs production behavior differs"
|
||||||
|
|
||||||
|
**Cause:** Local uses SQLite file, production uses distributed D1 - different performance/limits
|
||||||
|
**Solution:** Always test migrations on remote with `--remote` flag before production rollout
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# D1 Patterns & Best Practices
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function getUsers({ page, pageSize }: { page: number; pageSize: number }, env: Env) {
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const [countResult, dataResult] = await env.DB.batch([
|
||||||
|
env.DB.prepare('SELECT COUNT(*) as total FROM users'),
|
||||||
|
env.DB.prepare('SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?').bind(pageSize, offset)
|
||||||
|
]);
|
||||||
|
return { data: dataResult.results, total: countResult.results[0].total, page, pageSize, totalPages: Math.ceil(countResult.results[0].total / pageSize) };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Queries
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function searchUsers(filters: { name?: string; email?: string; active?: boolean }, env: Env) {
|
||||||
|
const conditions: string[] = [], params: (string | number | boolean | null)[] = [];
|
||||||
|
if (filters.name) { conditions.push('name LIKE ?'); params.push(`%${filters.name}%`); }
|
||||||
|
if (filters.email) { conditions.push('email = ?'); params.push(filters.email); }
|
||||||
|
if (filters.active !== undefined) { conditions.push('active = ?'); params.push(filters.active ? 1 : 0); }
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
return await env.DB.prepare(`SELECT * FROM users ${whereClause}`).bind(...params).all();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bulk Insert
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function bulkInsertUsers(users: Array<{ name: string; email: string }>, env: Env) {
|
||||||
|
const stmt = env.DB.prepare('INSERT INTO users (name, email) VALUES (?, ?)');
|
||||||
|
const batch = users.map(user => stmt.bind(user.name, user.email));
|
||||||
|
return await env.DB.batch(batch);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caching with KV
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function getCachedUser(userId: number, env: { DB: D1Database; CACHE: KVNamespace }) {
|
||||||
|
const cacheKey = `user:${userId}`;
|
||||||
|
const cached = await env.CACHE?.get(cacheKey, 'json');
|
||||||
|
if (cached) return cached;
|
||||||
|
const user = await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId).first();
|
||||||
|
if (user) await env.CACHE?.put(cacheKey, JSON.stringify(user), { expirationTtl: 300 });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Optimization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use indexes in WHERE clauses
|
||||||
|
const users = await env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(email).all();
|
||||||
|
|
||||||
|
// ✅ Limit result sets
|
||||||
|
const recentPosts = await env.DB.prepare('SELECT * FROM posts ORDER BY created_at DESC LIMIT 100').all();
|
||||||
|
|
||||||
|
// ✅ Use batch() for multiple independent queries
|
||||||
|
const [user, posts, comments] = await env.DB.batch([
|
||||||
|
env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId),
|
||||||
|
env.DB.prepare('SELECT * FROM posts WHERE user_id = ?').bind(userId),
|
||||||
|
env.DB.prepare('SELECT * FROM comments WHERE user_id = ?').bind(userId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ❌ Avoid N+1 queries
|
||||||
|
for (const post of posts) {
|
||||||
|
const author = await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(post.user_id).first(); // Bad: multiple round trips
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Use JOINs instead
|
||||||
|
const postsWithAuthors = await env.DB.prepare(`
|
||||||
|
SELECT posts.*, users.name as author_name
|
||||||
|
FROM posts
|
||||||
|
JOIN users ON posts.user_id = users.id
|
||||||
|
`).all();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Tenant SaaS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Each tenant gets own database
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: { [key: `TENANT_${string}`]: D1Database }) {
|
||||||
|
const tenantId = request.headers.get('X-Tenant-ID');
|
||||||
|
const data = await env[`TENANT_${tenantId}`].prepare('SELECT * FROM records').all();
|
||||||
|
return Response.json(data.results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Storage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function createSession(userId: number, token: string, env: Env) {
|
||||||
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
return await env.DB.prepare('INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, ?)').bind(userId, token, expiresAt).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateSession(token: string, env: Env) {
|
||||||
|
return await env.DB.prepare('SELECT s.*, u.email FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token = ? AND s.expires_at > CURRENT_TIMESTAMP').bind(token).first();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Analytics/Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function logEvent(event: { type: string; userId?: number; metadata: object }, env: Env) {
|
||||||
|
return await env.DB.prepare('INSERT INTO events (type, user_id, metadata) VALUES (?, ?, ?)').bind(event.type, event.userId || null, JSON.stringify(event.metadata)).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEventStats(startDate: string, endDate: string, env: Env) {
|
||||||
|
return await env.DB.prepare('SELECT type, COUNT(*) as count FROM events WHERE timestamp BETWEEN ? AND ? GROUP BY type ORDER BY count DESC').bind(startDate, endDate).all();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Read Replication Pattern (Paid Plans)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Env { DB: D1Database; DB_REPLICA: D1Database; }
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
// Reads: use replica for lower latency
|
||||||
|
const users = await env.DB_REPLICA.prepare('SELECT * FROM users WHERE active = 1').all();
|
||||||
|
return Response.json(users.results);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
const { name, email } = await request.json();
|
||||||
|
const result = await env.DB.prepare('INSERT INTO users (name, email) VALUES (?, ?)').bind(name, email).run();
|
||||||
|
|
||||||
|
// Read-after-write: use primary for consistency (replication lag <100ms-2s)
|
||||||
|
const user = await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(result.meta.last_row_id).first();
|
||||||
|
return Response.json(user, { status: 201 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use replicas for**: Analytics dashboards, search results, public queries (eventual consistency OK)
|
||||||
|
**Use primary for**: Read-after-write, financial transactions, authentication (consistency required)
|
||||||
|
|
||||||
|
## Sessions API Pattern (Paid Plans)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Migration with long-running session (up to 15 min)
|
||||||
|
async function runMigration(env: Env) {
|
||||||
|
const session = env.DB.withSession({ timeout: 600 }); // 10 min
|
||||||
|
try {
|
||||||
|
await session.prepare('CREATE INDEX idx_users_email ON users(email)').run();
|
||||||
|
await session.prepare('CREATE INDEX idx_posts_user ON posts(user_id)').run();
|
||||||
|
await session.prepare('ANALYZE').run();
|
||||||
|
} finally {
|
||||||
|
session.close(); // Always close to prevent leaks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk transformation with batching
|
||||||
|
async function transformLargeDataset(env: Env) {
|
||||||
|
const session = env.DB.withSession({ timeout: 900 }); // 15 min max
|
||||||
|
try {
|
||||||
|
const BATCH_SIZE = 1000;
|
||||||
|
let offset = 0;
|
||||||
|
while (true) {
|
||||||
|
const rows = await session.prepare('SELECT id, data FROM legacy LIMIT ? OFFSET ?').bind(BATCH_SIZE, offset).all();
|
||||||
|
if (rows.results.length === 0) break;
|
||||||
|
const updates = rows.results.map(row =>
|
||||||
|
session.prepare('UPDATE legacy SET new_data = ? WHERE id = ?').bind(transform(row.data), row.id)
|
||||||
|
);
|
||||||
|
await session.batch(updates);
|
||||||
|
offset += BATCH_SIZE;
|
||||||
|
}
|
||||||
|
} finally { session.close(); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Time Travel & Backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler d1 time-travel restore <db-name> --timestamp="2024-01-15T14:30:00Z" # Point-in-time
|
||||||
|
wrangler d1 time-travel info <db-name> # List restore points (7 days free, 30 days paid)
|
||||||
|
wrangler d1 export <db-name> --remote --output=./backup.sql # Full export
|
||||||
|
wrangler d1 export <db-name> --remote --no-schema --output=./data.sql # Data only
|
||||||
|
wrangler d1 execute <db-name> --remote --file=./backup.sql # Import
|
||||||
|
```
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Cloudflare DDoS Protection
|
||||||
|
|
||||||
|
Autonomous, always-on protection against DDoS attacks across L3/4 and L7.
|
||||||
|
|
||||||
|
## Protection Types
|
||||||
|
|
||||||
|
- **HTTP DDoS (L7)**: Protects HTTP/HTTPS traffic, phase `ddos_l7`, zone/account level
|
||||||
|
- **Network DDoS (L3/4)**: UDP/SYN/DNS floods, phase `ddos_l4`, account level only
|
||||||
|
- **Adaptive DDoS**: Learns 7-day baseline, detects deviations, 4 profile types (Origins, User-Agents, Locations, Protocols)
|
||||||
|
|
||||||
|
## Plan Availability
|
||||||
|
|
||||||
|
| Feature | Free | Pro | Business | Enterprise | Enterprise Advanced |
|
||||||
|
|---------|------|-----|----------|------------|---------------------|
|
||||||
|
| HTTP DDoS (L7) | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Network DDoS (L3/4) | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Override rules | 1 | 1 | 1 | 1 | 10 |
|
||||||
|
| Custom expressions | ✗ | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| Log action | ✗ | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| Adaptive DDoS | ✗ | ✗ | ✗ | ✓ | ✓ |
|
||||||
|
| Alert filters | Basic | Basic | Basic | Advanced | Advanced |
|
||||||
|
|
||||||
|
## Actions & Sensitivity
|
||||||
|
|
||||||
|
- **Actions**: `block`, `managed_challenge`, `challenge`, `log` (Enterprise Advanced only)
|
||||||
|
- **Sensitivity**: `default` (high), `medium`, `low`, `eoff` (essentially off)
|
||||||
|
- **Override**: By category/tag or individual rule ID
|
||||||
|
- **Scope**: Zone-level overrides take precedence over account-level
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
| File | Purpose | Start Here If... |
|
||||||
|
|------|---------|------------------|
|
||||||
|
| [configuration.md](./configuration.md) | Dashboard setup, rule structure, adaptive profiles | You're setting up DDoS protection for the first time |
|
||||||
|
| [api.md](./api.md) | API endpoints, SDK usage, ruleset ID discovery | You're automating configuration or need programmatic access |
|
||||||
|
| [patterns.md](./patterns.md) | Protection strategies, defense-in-depth, dynamic response | You need implementation patterns or layered security |
|
||||||
|
| [gotchas.md](./gotchas.md) | False positives, tuning, error handling | You're troubleshooting or optimizing existing protection |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
- [waf](../waf/) - Application-layer security rules
|
||||||
|
- [bot-management](../bot-management/) - Bot detection and mitigation
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# DDoS API
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### HTTP DDoS (L7)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Zone-level
|
||||||
|
PUT /zones/{zoneId}/rulesets/phases/ddos_l7/entrypoint
|
||||||
|
GET /zones/{zoneId}/rulesets/phases/ddos_l7/entrypoint
|
||||||
|
|
||||||
|
// Account-level (Enterprise Advanced)
|
||||||
|
PUT /accounts/{accountId}/rulesets/phases/ddos_l7/entrypoint
|
||||||
|
GET /accounts/{accountId}/rulesets/phases/ddos_l7/entrypoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network DDoS (L3/4)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Account-level only
|
||||||
|
PUT /accounts/{accountId}/rulesets/phases/ddos_l4/entrypoint
|
||||||
|
GET /accounts/{accountId}/rulesets/phases/ddos_l4/entrypoint
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript SDK
|
||||||
|
|
||||||
|
**SDK Version**: Requires `cloudflare` >= 3.0.0 for ruleset phase methods.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Cloudflare from "cloudflare";
|
||||||
|
|
||||||
|
const client = new Cloudflare({ apiToken: process.env.CLOUDFLARE_API_TOKEN });
|
||||||
|
|
||||||
|
// STEP 1: Discover managed ruleset ID (required for overrides)
|
||||||
|
const allRulesets = await client.rulesets.list({ zone_id: zoneId });
|
||||||
|
const ddosRuleset = allRulesets.result.find(
|
||||||
|
(r) => r.kind === "managed" && r.phase === "ddos_l7"
|
||||||
|
);
|
||||||
|
if (!ddosRuleset) throw new Error("DDoS managed ruleset not found");
|
||||||
|
const managedRulesetId = ddosRuleset.id;
|
||||||
|
|
||||||
|
// STEP 2: Get current HTTP DDoS configuration
|
||||||
|
const entrypointRuleset = await client.zones.rulesets.phases.entrypoint.get("ddos_l7", {
|
||||||
|
zone_id: zoneId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// STEP 3: Update HTTP DDoS ruleset with overrides
|
||||||
|
await client.zones.rulesets.phases.entrypoint.update("ddos_l7", {
|
||||||
|
zone_id: zoneId,
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
action: "execute",
|
||||||
|
expression: "true",
|
||||||
|
action_parameters: {
|
||||||
|
id: managedRulesetId, // From discovery step
|
||||||
|
overrides: {
|
||||||
|
sensitivity_level: "medium",
|
||||||
|
action: "managed_challenge",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Network DDoS (account level, L3/4)
|
||||||
|
const l4Rulesets = await client.rulesets.list({ account_id: accountId });
|
||||||
|
const l4DdosRuleset = l4Rulesets.result.find(
|
||||||
|
(r) => r.kind === "managed" && r.phase === "ddos_l4"
|
||||||
|
);
|
||||||
|
const l4Ruleset = await client.accounts.rulesets.phases.entrypoint.get("ddos_l4", {
|
||||||
|
account_id: accountId,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alert Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DDoSAlertConfig {
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
alert_type: "http_ddos_attack_alert" | "layer_3_4_ddos_attack_alert"
|
||||||
|
| "advanced_http_ddos_attack_alert" | "advanced_layer_3_4_ddos_attack_alert";
|
||||||
|
filters?: {
|
||||||
|
zones?: string[];
|
||||||
|
hostnames?: string[];
|
||||||
|
requests_per_second?: number;
|
||||||
|
packets_per_second?: number;
|
||||||
|
megabits_per_second?: number;
|
||||||
|
ip_prefixes?: string[]; // CIDR
|
||||||
|
ip_addresses?: string[];
|
||||||
|
protocols?: string[];
|
||||||
|
};
|
||||||
|
mechanisms: {
|
||||||
|
email?: Array<{ id: string }>;
|
||||||
|
webhooks?: Array<{ id: string }>;
|
||||||
|
pagerduty?: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create alert
|
||||||
|
await fetch(
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${accountId}/alerting/v3/policies`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(alertConfig),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typed Override Examples
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Override by category
|
||||||
|
interface CategoryOverride {
|
||||||
|
action: "execute";
|
||||||
|
expression: string;
|
||||||
|
action_parameters: {
|
||||||
|
id: string;
|
||||||
|
overrides: {
|
||||||
|
categories?: Array<{
|
||||||
|
category: "http-flood" | "http-anomaly" | "udp-flood" | "syn-flood";
|
||||||
|
sensitivity_level?: "default" | "medium" | "low" | "eoff";
|
||||||
|
action?: "block" | "managed_challenge" | "challenge" | "log";
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override by rule ID
|
||||||
|
interface RuleOverride {
|
||||||
|
action: "execute";
|
||||||
|
expression: string;
|
||||||
|
action_parameters: {
|
||||||
|
id: string;
|
||||||
|
overrides: {
|
||||||
|
rules?: Array<{
|
||||||
|
id: string;
|
||||||
|
action?: "block" | "managed_challenge" | "challenge" | "log";
|
||||||
|
sensitivity_level?: "default" | "medium" | "low" | "eoff";
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: Override specific adaptive rule
|
||||||
|
const adaptiveOverride: RuleOverride = {
|
||||||
|
action: "execute",
|
||||||
|
expression: "true",
|
||||||
|
action_parameters: {
|
||||||
|
id: managedRulesetId,
|
||||||
|
overrides: {
|
||||||
|
rules: [
|
||||||
|
{ id: "...adaptive-origins-rule-id...", sensitivity_level: "low" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
See [patterns.md](./patterns.md) for complete implementation patterns.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# DDoS Configuration
|
||||||
|
|
||||||
|
## Dashboard Setup
|
||||||
|
|
||||||
|
1. Navigate to Security > DDoS
|
||||||
|
2. Select HTTP DDoS or Network-layer DDoS
|
||||||
|
3. Configure sensitivity & action per ruleset/category/rule
|
||||||
|
4. Apply overrides with optional expressions (Enterprise Advanced)
|
||||||
|
5. Enable Adaptive DDoS toggle (Enterprise/Enterprise Advanced, requires 7 days traffic history)
|
||||||
|
|
||||||
|
## Rule Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DDoSOverride {
|
||||||
|
description: string;
|
||||||
|
rules: Array<{
|
||||||
|
action: "execute";
|
||||||
|
expression: string; // Custom expression (Enterprise Advanced) or "true" for all
|
||||||
|
action_parameters: {
|
||||||
|
id: string; // Managed ruleset ID (discover via api.md)
|
||||||
|
overrides: {
|
||||||
|
sensitivity_level?: "default" | "medium" | "low" | "eoff";
|
||||||
|
action?: "block" | "managed_challenge" | "challenge" | "log"; // log = Enterprise Advanced only
|
||||||
|
categories?: Array<{
|
||||||
|
category: string; // e.g., "http-flood", "udp-flood"
|
||||||
|
sensitivity_level?: string;
|
||||||
|
}>;
|
||||||
|
rules?: Array<{
|
||||||
|
id: string;
|
||||||
|
action?: string;
|
||||||
|
sensitivity_level?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expression Availability
|
||||||
|
|
||||||
|
| Plan | Custom Expressions | Example |
|
||||||
|
|------|-------------------|---------|
|
||||||
|
| Free/Pro/Business | ✗ | Use `"true"` only |
|
||||||
|
| Enterprise | ✗ | Use `"true"` only |
|
||||||
|
| Enterprise Advanced | ✓ | `ip.src in {...}`, `http.request.uri.path matches "..."` |
|
||||||
|
|
||||||
|
## Sensitivity Mapping
|
||||||
|
|
||||||
|
| UI | API | Threshold |
|
||||||
|
|----|-----|-----------|
|
||||||
|
| High | `default` | Most aggressive |
|
||||||
|
| Medium | `medium` | Balanced |
|
||||||
|
| Low | `low` | Less aggressive |
|
||||||
|
| Essentially Off | `eoff` | Minimal mitigation |
|
||||||
|
|
||||||
|
## Common Categories
|
||||||
|
|
||||||
|
- `http-flood`, `http-anomaly` (L7)
|
||||||
|
- `udp-flood`, `syn-flood`, `dns-flood` (L3/4)
|
||||||
|
|
||||||
|
## Override Precedence
|
||||||
|
|
||||||
|
Multiple override layers apply in this order (higher precedence wins):
|
||||||
|
|
||||||
|
```
|
||||||
|
Zone-level > Account-level
|
||||||
|
Individual Rule > Category > Global sensitivity/action
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example**: Zone rule for `/api/*` overrides account-level global settings.
|
||||||
|
|
||||||
|
## Adaptive DDoS Profiles
|
||||||
|
|
||||||
|
**Availability**: Enterprise, Enterprise Advanced
|
||||||
|
**Learning period**: 7 days of traffic history required
|
||||||
|
|
||||||
|
| Profile Type | Description | Detects |
|
||||||
|
|--------------|-------------|---------|
|
||||||
|
| **Origins** | Traffic patterns per origin server | Anomalous requests to specific origins |
|
||||||
|
| **User-Agents** | Traffic patterns per User-Agent | Malicious/anomalous user agent strings |
|
||||||
|
| **Locations** | Traffic patterns per geo-location | Attacks from specific countries/regions |
|
||||||
|
| **Protocols** | Traffic patterns per protocol (L3/4) | Protocol-specific flood attacks |
|
||||||
|
|
||||||
|
Configure by targeting specific adaptive rule IDs via API (see api.md#typed-override-examples).
|
||||||
|
|
||||||
|
## Alerting
|
||||||
|
|
||||||
|
Configure via Notifications:
|
||||||
|
- Alert types: `http_ddos_attack_alert`, `layer_3_4_ddos_attack_alert`, `advanced_*` variants
|
||||||
|
- Filters: zones, hostnames, RPS/PPS/Mbps thresholds, IPs, protocols
|
||||||
|
- Mechanisms: email, webhooks, PagerDuty
|
||||||
|
|
||||||
|
See [api.md](./api.md#alert-configuration) for API examples.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# DDoS Gotchas
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### "False positives blocking legitimate traffic"
|
||||||
|
|
||||||
|
**Cause**: Sensitivity too high, wrong action, or missing exceptions
|
||||||
|
**Solution**:
|
||||||
|
1. Lower sensitivity for specific rule/category
|
||||||
|
2. Use `log` action first to validate (Enterprise Advanced)
|
||||||
|
3. Add exception with custom expression (e.g., allowlist IPs)
|
||||||
|
4. Query flagged requests via GraphQL Analytics API to identify patterns
|
||||||
|
|
||||||
|
### "Attacks getting through"
|
||||||
|
|
||||||
|
**Cause**: Sensitivity too low or wrong action
|
||||||
|
**Solution**: Increase to `default` sensitivity and use `block` action:
|
||||||
|
```typescript
|
||||||
|
const config = {
|
||||||
|
rules: [{
|
||||||
|
expression: "true",
|
||||||
|
action: "execute",
|
||||||
|
action_parameters: { id: managedRulesetId, overrides: { sensitivity_level: "default", action: "block" } },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Adaptive rules not working"
|
||||||
|
|
||||||
|
**Cause**: Insufficient traffic history (needs 7 days)
|
||||||
|
**Solution**: Wait for baseline to establish, check dashboard for adaptive rule status
|
||||||
|
|
||||||
|
### "Zone override ignored"
|
||||||
|
|
||||||
|
**Cause**: Account overrides conflict with zone overrides
|
||||||
|
**Solution**: Configure at zone level OR remove zone overrides to use account-level
|
||||||
|
|
||||||
|
### "Log action not available"
|
||||||
|
|
||||||
|
**Cause**: Not on Enterprise Advanced DDoS plan
|
||||||
|
**Solution**: Use `managed_challenge` with low sensitivity for testing
|
||||||
|
|
||||||
|
### "Rule limit exceeded"
|
||||||
|
|
||||||
|
**Cause**: Too many override rules (Free/Pro/Business: 1, Enterprise Advanced: 10)
|
||||||
|
**Solution**: Combine conditions in single expression using `and`/`or`
|
||||||
|
|
||||||
|
### "Cannot override rule"
|
||||||
|
|
||||||
|
**Cause**: Rule is read-only
|
||||||
|
**Solution**: Check API response for read-only indicator, use different rule
|
||||||
|
|
||||||
|
### "Cannot disable DDoS protection"
|
||||||
|
|
||||||
|
**Cause**: DDoS managed rulesets cannot be fully disabled (always-on protection)
|
||||||
|
**Solution**: Set `sensitivity_level: "eoff"` for minimal mitigation
|
||||||
|
|
||||||
|
### "Expression not allowed"
|
||||||
|
|
||||||
|
**Cause**: Custom expressions require Enterprise Advanced plan
|
||||||
|
**Solution**: Use `expression: "true"` for all traffic, or upgrade plan
|
||||||
|
|
||||||
|
### "Managed ruleset not found"
|
||||||
|
|
||||||
|
**Cause**: Zone/account doesn't have DDoS managed ruleset, or incorrect phase
|
||||||
|
**Solution**: Verify ruleset exists via `client.rulesets.list()`, check phase name (`ddos_l7` or `ddos_l4`)
|
||||||
|
|
||||||
|
## API Error Codes
|
||||||
|
|
||||||
|
| Error Code | Message | Cause | Solution |
|
||||||
|
|------------|---------|-------|----------|
|
||||||
|
| 10000 | Authentication error | Invalid/missing API token | Check token has DDoS permissions |
|
||||||
|
| 81000 | Ruleset validation failed | Invalid rule structure | Verify `action_parameters.id` is managed ruleset ID |
|
||||||
|
| 81020 | Expression not allowed | Custom expressions on wrong plan | Use `"true"` or upgrade to Enterprise Advanced |
|
||||||
|
| 81021 | Rule limit exceeded | Too many override rules | Reduce rules or upgrade (Enterprise Advanced: 10) |
|
||||||
|
| 81022 | Invalid sensitivity level | Wrong sensitivity value | Use: `default`, `medium`, `low`, `eoff` |
|
||||||
|
| 81023 | Invalid action | Wrong action for plan | Enterprise Advanced only: `log` action |
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
| Resource/Limit | Free/Pro/Business | Enterprise | Enterprise Advanced |
|
||||||
|
|----------------|-------------------|------------|---------------------|
|
||||||
|
| Override rules per zone | 1 | 1 | 10 |
|
||||||
|
| Custom expressions | ✗ | ✗ | ✓ |
|
||||||
|
| Log action | ✗ | ✗ | ✓ |
|
||||||
|
| Adaptive DDoS | ✗ | ✓ | ✓ |
|
||||||
|
| Traffic history required | - | 7 days | 7 days |
|
||||||
|
|
||||||
|
## Tuning Strategy
|
||||||
|
|
||||||
|
1. Start with `log` action + `medium` sensitivity
|
||||||
|
2. Monitor for 24-48 hours
|
||||||
|
3. Identify false positives, add exceptions
|
||||||
|
4. Gradually increase to `default` sensitivity
|
||||||
|
5. Change action from `log` → `managed_challenge` → `block`
|
||||||
|
6. Document all adjustments
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Test during low-traffic periods
|
||||||
|
- Use zone-level for per-site tuning
|
||||||
|
- Reference IP lists for easier management
|
||||||
|
- Set appropriate alert thresholds (avoid noise)
|
||||||
|
- Combine with WAF for layered defense
|
||||||
|
- Avoid over-tuning (keep config simple)
|
||||||
|
|
||||||
|
See [patterns.md](./patterns.md) for progressive rollout examples.
|
||||||
Loaded 100 of 637 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user