Accessibility Auditor
Run axe-core WCAG 2.1 AA accessibility audits with Browser Rendering and suggest alt text for unlabeled images via Workers AI vision
Audit any page for WCAG 2.0/2.1 A + AA issues by injecting axe-core into a Browser Rendering session, then optionally describe images that are missing alt text with a Workers AI vision model.
Features
- GET /audit - axe-core run on the fully rendered page, summarized into a compact JSON report
- Impact counts and score - violated rules grouped by
critical/serious/moderate/minor, plus a 0–100 weighted score - Alt text suggestions -
?altText=truedescribes up to 5<img>elements with missing or emptyalt - POST /alt-text - standalone alt text generation for an uploaded image or image URL
API Reference
GET /audit
Loads the page, injects axe-core, and runs rules tagged wcag2a, wcag2aa, wcag21a, wcag21aa.
Prop
Type
Example Request
curl "https://your-worker.workers.dev/audit?url=https://dequeuniversity.com/demo/mars/&altText=true"Success Response
{
"url": "https://dequeuniversity.com/demo/mars/",
"score": 35,
"counts": { "critical": 3, "serious": 5, "moderate": 0, "minor": 0 },
"violations": [
{
"id": "button-name",
"impact": "critical",
"help": "Buttons must have discernible text",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.13/button-name?application=axeAPI",
"totalNodes": 1,
"nodes": [
{
"target": [".departure-date > .ui-datepicker-trigger:nth-child(4)"],
"html": "<button class=\"ui-datepicker-trigger\" type=\"button\">…"
}
]
}
],
"passes": 29,
"incomplete": 3,
"altSuggestions": [
{
"src": "https://dequeuniversity.com/assets/demo-sites/mars/images/mars-spaceman.jpg",
"suggestion": "A space suit with a helmet on a rocky surface."
}
]
}counts object
Number of violated rules per impact level.
violations[].totalNodes number
Failing elements for that rule. At most 10 are returned in nodes, each with its CSS target (shadow DOM hops joined with >>>) and html truncated to 300 characters.
score number
100 × passes / (passes + penalty), where each violated rule adds 10 (critical), 5 (serious), 3 (moderate), or 1 (minor). 100 means no violations.
altSuggestions array (only with altText=true)
{ src, suggestion }. When an image can't be fetched or described, suggestion is null and error explains why.
POST /alt-text
Generate alt text for a single image. Send raw image bytes (Content-Type: image/*, max 2 MB) or pass an image URL.
Prop
Type
Example Request
curl -X POST "https://your-worker.workers.dev/alt-text" \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpgSuccess Response
{
"altText": "A red bicycle leaning against a brick wall.",
"model": "@cf/llava-hf/llava-1.5-7b-hf"
}Error Codes
400- Missing or invalidurl/image(INVALID_URL),altTextnottrue/false(INVALID_QUERY), empty body (MISSING_IMAGE)413- Image larger than 2 MB (PAYLOAD_TOO_LARGE)415- Body or remote image is notimage/*(UNSUPPORTED_MEDIA_TYPE)502- Navigation or axe-core injection failed (BROWSER_ERROR), remote image fetch failed (FETCH_ERROR), Workers AI call failed (AI_ERROR)
Implementation Details
- Injection -
page.addScriptTag({ content: axe.source })afterpage.setBypassCSP(true), so strict Content-Security-Policy headers don't block the inline script. If the page reloads itself (e.g.<meta http-equiv="refresh">) andwindow.axeis gone, axe is injected once more. keep_names: false-axe.sourceis axe-core's function stringified from the Worker bundle. With Wrangler's defaultkeep_names, esbuild injects__name()calls into that function that don't exist in the page, sowrangler.jsonturns it off.- Navigation - 1280×800 viewport,
networkidle2, 20 s timeout. - Vision model -
@cf/llava-hf/llava-1.5-7b-hfreceives{ image: number[], prompt, max_tokens: 80 }; prefixes like "Image of" are stripped from the output.
Use Cases
- Add an accessibility gate to CI or preview deployments
- Track WCAG regressions across a list of pages over time
- Bootstrap alt text for legacy image-heavy content
- Learn how to inject third-party scripts into Browser Rendering sessions
Limitations
- Automated checks catch only part of WCAG; manual review is still required
- Single page per request; no crawling or authenticated pages
- Alt text suggestions are a starting point. Review them for context, and use
alt=""for decorative images - Images over 2 MB, non-
image/*responses, anddata:URIs are skipped - Workers AI vision models are billed per request and can be slow on cold starts
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/accessibility-auditor.
npm install @cloudflare/puppeteer axe-coreimport puppeteer from "@cloudflare/puppeteer";import type { Page } from "@cloudflare/puppeteer";import axe from "axe-core";import { AXE_TAGS, DEFAULT_VIEWPORT, MAX_ALT_IMAGES, MAX_AXE_ATTEMPTS, NAVIGATION_TIMEOUT_MS,} from "../constants/defaults";import type { PageAuditResult } from "../types/audit";type AxeGlobal = { axe?: { run: ( context: Document, options: { runOnly: { type: "tag"; values: string[] }; resultTypes: string[] } ) => Promise<{ violations: { id: string; impact?: string | null; help: string; helpUrl: string; nodes: { target: (string | string[])[]; html: string }[]; }[]; passes: unknown[]; incomplete: unknown[]; }>; };};/** Returns null when `window.axe` is missing (e.g. a meta refresh reloaded the page after injection). */async function evaluateAxe(page: Page): Promise<PageAuditResult | null> { // Runs inside the page: must not reference module scope (pass values as args). return (await page.evaluate( async (tags: string[], maxImages: number) => { const axeGlobal = (window as unknown as AxeGlobal).axe; if (!axeGlobal) return null; const results = await axeGlobal.run(document, { runOnly: { type: "tag", values: tags }, resultTypes: ["violations"], }); const imagesMissingAlt = Array.from(document.querySelectorAll("img")) .filter( (img) => !img.getAttribute("alt")?.trim() && img.getAttribute("role") !== "presentation" ) .map((img) => img.currentSrc || img.src) .filter((src) => src.startsWith("http://") || src.startsWith("https://")) .filter((src, index, all) => all.indexOf(src) === index) .slice(0, maxImages); return { axe: { violations: results.violations.map((v) => ({ id: v.id, impact: v.impact ?? null, help: v.help, helpUrl: v.helpUrl, nodes: v.nodes.map((n) => ({ target: n.target, html: n.html })), })), passes: results.passes.length, incomplete: results.incomplete.length, }, imagesMissingAlt, }; }, AXE_TAGS, MAX_ALT_IMAGES )) as PageAuditResult | null;}/** Navigates to `url`, injects axe-core, and returns slim results plus `<img>` srcs lacking alt text. */export async function runAxeAudit(browserBinding: Fetcher, url: string): Promise<PageAuditResult> { let browser: Awaited<ReturnType<typeof puppeteer.launch>> | null = null; try { browser = await puppeteer.launch(browserBinding); const page = await browser.newPage(); await page.setViewport(DEFAULT_VIEWPORT); // Lets the inline axe <script> run on sites whose CSP forbids inline scripts. await page.setBypassCSP(true); await page.goto(url, { waitUntil: "networkidle2", timeout: NAVIGATION_TIMEOUT_MS }); for (let attempt = 0; attempt < MAX_AXE_ATTEMPTS; attempt++) { // axe.source is axe's bundled function stringified; wrangler.json sets keep_names: false // so esbuild doesn't inject __name() calls into it that are undefined in the page. await page.addScriptTag({ content: axe.source }); const result = await evaluateAxe(page); if (result) return result; } throw new Error("axe-core could not be loaded into the page"); } finally { if (browser) { try { await browser.close(); } catch { /* ignore close errors */ } } }}Set "keep_names": false in your wrangler.json, or the injected axe-core source will throw __name is not defined in the page.
Deployment
Enable Browser Rendering and Workers AI
The Worker uses the BROWSER and AI bindings declared in wrangler.json. Both must be available on your account. See the Browser Rendering docs and Workers AI docs.
Test your deployment
curl "https://your-worker.workers.dev/audit?url=https://example.com"Local Development
cd apps/experiments/accessibility-auditor
npm install
npm run devWorkers AI always runs remotely, even in wrangler dev, so run wrangler login first. Use npx wrangler dev --remote if a local browser isn't available.
curl "http://localhost:8787/audit?url=https://example.com&altText=true"Configuration
wrangler.json declares:
browserbindingBROWSERaibindingAIkeep_names: false- required foraxe.sourceinjection
Dependencies: @cloudflare/puppeteer, axe-core, hono.
Cloudflare Features Used
- Workers - Edge compute runtime
- Browser Rendering - Headless Chromium via
@cloudflare/puppeteer - Workers AI -
@cf/llava-hf/llava-1.5-7b-hfimage-to-text for alt text