Add in-app CORS playground + mock.cors.sh test API#172
Conversation
Replace the dormant external Hoppscotch fork (cors.sh/playground rewrote to playground.cors.sh on Vercel, which 503'd when idle-paused) with a small custom in-app page. - web/app/(home)/playground: a client tester that fires the SAME request directly and via proxy.cors.sh in the browser's own fetch(), side by side — direct is blocked by CORS, proxied returns 200 with body + headers (access-control-* highlighted). Method, optional x-cors-api-key, headers, body; copy-as-fetch snippet. Works keyless (anonymous tier). - Deploy workers/mock to mock.cors.sh (custom domain) as the default target: a purpose-built CORS-reject API (/no-cors, /wrong-origin, /needs-preflight, /allow-all) we control, so the 'blocked' case is reliable and returns clean JSON. Wired into deploy.yml. - Remove the /playground -> playground.cors.sh rewrite. Proxy base comes from a runtime binding (PLAYGROUND_PROXY_URL, default proxy.cors.sh) so the e2e points it at the local proxy. - New tests/e2e/playground.spec.ts (real browser: direct blocked, proxied 200 + injected ACAO). format/lint/check-types + build + 21/21 e2e green; mock.cors.sh verified live.
The README still described the playground as a Hoppscotch fork. Rewrite the Playground section for the new in-app CORS tester + mock.cors.sh, and update the disclaimer (credits cors-anywhere + Hoppscotch historically; current service is a Cloudflare rebuild).
WalkthroughAdded a new ChangesBuilt-in playground and mock API
Sequence Diagram(s)sequenceDiagram
participant Browser
participant PlaygroundPage
participant Playground
participant proxy.cors.sh
participant mock.cors.sh
Browser->>PlaygroundPage: GET /playground
PlaygroundPage->>Playground: pass proxyBase
Browser->>Playground: click Run
Playground->>mock.cors.sh: direct fetch()
Playground->>proxy.cors.sh: proxied fetch()
proxy.cors.sh->>mock.cors.sh: forward request
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
tests/e2e/playground.spec.tsOops! Something went wrong! :( ESLint: 9.39.4 Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './library.js' is not defined by "exports" in /node_modules/@workspace/eslint-config/package.json tests/e2e/playwright.config.tsOops! Something went wrong! :( ESLint: 9.39.4 Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './library.js' is not defined by "exports" in /node_modules/@workspace/eslint-config/package.json Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@web/components/playground.tsx`:
- Around line 114-137: The proxied playground request is still relying on the
normalized `${base}/${url}` path, so some valid targets can be rewritten before
reaching the worker. Update the proxied branch in `run()` to send the raw target
URL via the `x-strict-request-url` header alongside the existing
`x-cors-api-key` header, and mirror the same header in the generated `fetch()`
sample so the copied snippet preserves the exact target. Use the existing
`proxiedUrl`, `timedFetch`, and request-building logic in `playground.tsx` to
apply the fix consistently.
- Around line 66-99: timedFetch currently awaits fetch() with no deadline, so a
stalled request can keep run() stuck in “Running…”. Add a timeout inside
timedFetch using AbortController (or equivalent) and pass its signal to fetch(),
then clear the timer in both success and error paths. Keep the existing
RunResult shape and ensure the timeout surfaces as a handled error in timedFetch
rather than hanging the caller.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 0f4e5cd4-e47b-45c8-b86e-1c61e063702b
📒 Files selected for processing (10)
.github/workflows/deploy.ymlREADME.mdscripts/stack.shtests/e2e/playground.spec.tstests/e2e/playwright.config.tsweb/app/(home)/playground/page.tsxweb/cloudflare-env.d.tsweb/components/playground.tsxweb/next.config.mjsworkers/mock/wrangler.jsonc
💤 Files with no reviewable changes (1)
- web/next.config.mjs
| async function timedFetch(url: string, init: RequestInit): Promise<RunResult> { | ||
| const start = performance.now(); | ||
| try { | ||
| const r = await fetch(url, init); | ||
| const ms = Math.round(performance.now() - start); | ||
| const raw = await r.text(); | ||
| const fullSize = raw.length; | ||
| let body = raw; | ||
| // Pretty-print JSON when parseable; otherwise show as-is. | ||
| try { | ||
| body = JSON.stringify(JSON.parse(raw), null, 2); | ||
| } catch { | ||
| // not JSON — leave raw text | ||
| } | ||
| const truncated = body.length > MAX_BODY; | ||
| return { | ||
| ok: true, | ||
| status: r.status, | ||
| statusText: r.statusText, | ||
| ms, | ||
| headers: [...r.headers.entries()], | ||
| body: truncated ? body.slice(0, MAX_BODY) : body, | ||
| truncated, | ||
| fullSize, | ||
| }; | ||
| } catch (e) { | ||
| const err = e as Error; | ||
| return { | ||
| ok: false, | ||
| ms: Math.round(performance.now() - start), | ||
| errorName: err.name || "Error", | ||
| errorMessage: String(err.message || err), | ||
| }; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Add a timeout to timedFetch().
fetch() has no default timeout. Because run() waits on both legs and disables the button while pending, one blackholed upstream can leave the whole playground stuck in “Running…” indefinitely.
Proposed fix
const MAX_BODY = 100 * 1024; // cap what we render; bodies can be huge
+const REQUEST_TIMEOUT_MS = 15_000;
@@
async function timedFetch(url: string, init: RequestInit): Promise<RunResult> {
const start = performance.now();
+ const controller = new AbortController();
+ const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
- const r = await fetch(url, init);
+ const r = await fetch(url, { ...init, signal: controller.signal });
const ms = Math.round(performance.now() - start);
@@
} catch (e) {
const err = e as Error;
return {
ok: false,
ms: Math.round(performance.now() - start),
errorName: err.name || "Error",
- errorMessage: String(err.message || err),
+ errorMessage:
+ err.name === "AbortError"
+ ? `Timed out after ${REQUEST_TIMEOUT_MS / 1000}s`
+ : String(err.message || err),
};
+ } finally {
+ window.clearTimeout(timeoutId);
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async function timedFetch(url: string, init: RequestInit): Promise<RunResult> { | |
| const start = performance.now(); | |
| try { | |
| const r = await fetch(url, init); | |
| const ms = Math.round(performance.now() - start); | |
| const raw = await r.text(); | |
| const fullSize = raw.length; | |
| let body = raw; | |
| // Pretty-print JSON when parseable; otherwise show as-is. | |
| try { | |
| body = JSON.stringify(JSON.parse(raw), null, 2); | |
| } catch { | |
| // not JSON — leave raw text | |
| } | |
| const truncated = body.length > MAX_BODY; | |
| return { | |
| ok: true, | |
| status: r.status, | |
| statusText: r.statusText, | |
| ms, | |
| headers: [...r.headers.entries()], | |
| body: truncated ? body.slice(0, MAX_BODY) : body, | |
| truncated, | |
| fullSize, | |
| }; | |
| } catch (e) { | |
| const err = e as Error; | |
| return { | |
| ok: false, | |
| ms: Math.round(performance.now() - start), | |
| errorName: err.name || "Error", | |
| errorMessage: String(err.message || err), | |
| }; | |
| } | |
| const REQUEST_TIMEOUT_MS = 15_000; | |
| async function timedFetch(url: string, init: RequestInit): Promise<RunResult> { | |
| const start = performance.now(); | |
| const controller = new AbortController(); | |
| const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); | |
| try { | |
| const r = await fetch(url, { ...init, signal: controller.signal }); | |
| const ms = Math.round(performance.now() - start); | |
| const raw = await r.text(); | |
| const fullSize = raw.length; | |
| let body = raw; | |
| // Pretty-print JSON when parseable; otherwise show as-is. | |
| try { | |
| body = JSON.stringify(JSON.parse(raw), null, 2); | |
| } catch { | |
| // not JSON — leave raw text | |
| } | |
| const truncated = body.length > MAX_BODY; | |
| return { | |
| ok: true, | |
| status: r.status, | |
| statusText: r.statusText, | |
| ms, | |
| headers: [...r.headers.entries()], | |
| body: truncated ? body.slice(0, MAX_BODY) : body, | |
| truncated, | |
| fullSize, | |
| }; | |
| } catch (e) { | |
| const err = e as Error; | |
| return { | |
| ok: false, | |
| ms: Math.round(performance.now() - start), | |
| errorName: err.name || "Error", | |
| errorMessage: | |
| err.name === "AbortError" | |
| ? `Timed out after ${REQUEST_TIMEOUT_MS / 1000}s` | |
| : String(err.message || err), | |
| }; | |
| } finally { | |
| window.clearTimeout(timeoutId); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/components/playground.tsx` around lines 66 - 99, timedFetch currently
awaits fetch() with no deadline, so a stalled request can keep run() stuck in
“Running…”. Add a timeout inside timedFetch using AbortController (or
equivalent) and pass its signal to fetch(), then clear the timer in both success
and error paths. Keep the existing RunResult shape and ensure the timeout
surfaces as a handled error in timedFetch rather than hanging the caller.
| const base = proxyBase.replace(/\/$/, ""); | ||
| const proxiedUrl = `${base}/${url}`; | ||
|
|
||
| async function run() { | ||
| if (!/^https?:\/\//i.test(url)) { | ||
| toast.error("Enter an absolute http(s) URL"); | ||
| return; | ||
| } | ||
| setPending(true); | ||
| setDirect(null); | ||
| setProxied(null); | ||
|
|
||
| const headers = parseHeaders(headersText); | ||
| const init: RequestInit = { | ||
| method, | ||
| headers, | ||
| body: hasBody && body ? body : undefined, | ||
| }; | ||
| const [d, p] = await Promise.all([ | ||
| timedFetch(url, init), // direct: no key, the browser enforces CORS | ||
| timedFetch(proxiedUrl, { | ||
| ...init, | ||
| headers: { ...headers, ...(apiKey ? { "x-cors-api-key": apiKey } : {}) }, | ||
| }), |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Send the raw target in x-strict-request-url for proxied runs.
The proxy already prefers x-strict-request-url to avoid path normalization mangling, but the playground only sends ${base}/${url}. That means some valid targets can be rewritten before the worker sees them, and the copied snippet inherits the same bug. Add the strict header on proxied requests and in the generated fetch() sample so arbitrary URLs round-trip correctly.
Proposed fix
const hasBody = method !== "GET";
const base = proxyBase.replace(/\/$/, "");
const proxiedUrl = `${base}/${url}`;
@@
const headers = parseHeaders(headersText);
+ const proxyHeaders = {
+ ...headers,
+ "x-strict-request-url": url,
+ ...(apiKey ? { "x-cors-api-key": apiKey } : {}),
+ };
const init: RequestInit = {
method,
headers,
body: hasBody && body ? body : undefined,
};
const [d, p] = await Promise.all([
timedFetch(url, init), // direct: no key, the browser enforces CORS
timedFetch(proxiedUrl, {
...init,
- headers: { ...headers, ...(apiKey ? { "x-cors-api-key": apiKey } : {}) },
+ headers: proxyHeaders,
}),
]);
@@
for (const [k, v] of Object.entries(parseHeaders(headersText))) {
out.push(` ${JSON.stringify(k)}: ${JSON.stringify(v)},`);
}
out.push(
+ ` "x-strict-request-url": ${JSON.stringify(url)},`,
apiKey
? ` "x-cors-api-key": ${JSON.stringify(apiKey)},`
: ` // "x-cors-api-key": "live_…", // optional — anonymous works too`,
);Also applies to: 144-160
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/components/playground.tsx` around lines 114 - 137, The proxied playground
request is still relying on the normalized `${base}/${url}` path, so some valid
targets can be rewritten before reaching the worker. Update the proxied branch
in `run()` to send the raw target URL via the `x-strict-request-url` header
alongside the existing `x-cors-api-key` header, and mirror the same header in
the generated `fetch()` sample so the copied snippet preserves the exact target.
Use the existing `proxiedUrl`, `timedFetch`, and request-building logic in
`playground.tsx` to apply the fix consistently.
Summary
Replaces the
cors.sh/playgroundpage — which rewrote to a 4-year-old Hoppscotch fork (gridaco/playground.cors.sh) on Vercel and 503'd when that project idle-paused — with a small, custom in-app CORS tester, plus a public test API (mock.cors.sh) we control.After researching off-the-shelf options (embeddable API consoles, HTTP clients, CORS testers), none fit: they either need an OpenAPI spec or proxy around CORS — the opposite of what we need to demonstrate. So this is a hand-rolled component (the de-facto standard in this space — every CORS-proxy project ships its own small fetch form).
What's in it
/playground(web/app/(home)/playground+web/components/playground.tsx)proxy.cors.shside by side, in the browser's ownfetch()so CORS is genuinely enforced.200with the response body (pretty-printed JSON) and headers (the cors.sh-injectedaccess-control-*highlighted as the proof).x-cors-api-key, request headers, and body (advanced disclosure); copy-as-fetch()snippet. Works keyless (anonymous tier).mock.cors.sh— deployedworkers/mockto a custom domain as the default target. A purpose-built CORS-reject API (/no-cors,/wrong-origin,/needs-preflight,/allow-all) on a different origin thancors.sh, so the "blocked" demo is reliable (no dependence on a third party's CORS config) and returns clean JSON. Added todeploy.yml.Wiring
/playground → playground.cors.shrewrite innext.config.mjs.PLAYGROUND_PROXY_URL(defaultproxy.cors.sh);stack.shoverrides it to the local proxy so the e2e is self-contained.tests/e2e/playground.spec.ts(+ playwright project): a real browser asserts direct-blocked + proxied-200+ the injectedaccess-control-allow-origin.Verification
pnpm format/lint/check-types✅,pnpm build✅ (/playgroundis a dynamic route)pnpm test:e2e✅ 21/21 (the new spec + no regressions)mock.cors.shverified live: direct →200with no ACAO (browser blocks); via proxy →200+access-control-allow-origin: *Notes for reviewers
mock.cors.shis already deployed (custom domain on the cors.sh zone, likeproxy.cors.sh). The/playgrounddefault takes effect whenwebnext deploys.cors-mock-devto match the existingcors-proxy-dev/cors-web-dev; a-dev→prod rename is a separate cleanup.Summary by CodeRabbit
New Features
Bug Fixes