Skip to content

Add in-app CORS playground + mock.cors.sh test API#172

Merged
softmarshmallow merged 2 commits into
mainfrom
playground
Jun 26, 2026
Merged

Add in-app CORS playground + mock.cors.sh test API#172
softmarshmallow merged 2 commits into
mainfrom
playground

Conversation

@softmarshmallow

@softmarshmallow softmarshmallow commented Jun 26, 2026

Copy link
Copy Markdown
Member

Summary

Replaces the cors.sh/playground page — 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)

  • Paste a URL → Run → fires the same request directly and via proxy.cors.sh side by side, in the browser's own fetch() so CORS is genuinely enforced.
  • Direct → blocked, with honest copy (JS can't read the CORS reason; points to DevTools — it never fakes a "CORS" label). Proxied → 200 with the response body (pretty-printed JSON) and headers (the cors.sh-injected access-control-* highlighted as the proof).
  • Method, optional x-cors-api-key, request headers, and body (advanced disclosure); copy-as-fetch() snippet. Works keyless (anonymous tier).

mock.cors.sh — deployed workers/mock to 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 than cors.sh, so the "blocked" demo is reliable (no dependence on a third party's CORS config) and returns clean JSON. Added to deploy.yml.

Wiring

  • Removed the /playground → playground.cors.sh rewrite in next.config.mjs.
  • Proxy base read from a runtime binding PLAYGROUND_PROXY_URL (default proxy.cors.sh); stack.sh overrides it to the local proxy so the e2e is self-contained.
  • New tests/e2e/playground.spec.ts (+ playwright project): a real browser asserts direct-blocked + proxied-200 + the injected access-control-allow-origin.

Verification

  • pnpm format / lint / check-types ✅, pnpm build ✅ (/playground is a dynamic route)
  • pnpm test:e2e21/21 (the new spec + no regressions)
  • Driven live in a browser (dark + light, desktop + mobile) — fixed horizontal overflow, tab layout, and the status badge during polish
  • mock.cors.sh verified live: direct → 200 with no ACAO (browser blocks); via proxy → 200 + access-control-allow-origin: *

Notes for reviewers

  • mock.cors.sh is already deployed (custom domain on the cors.sh zone, like proxy.cors.sh). The /playground default takes effect when web next deploys.
  • The mock worker is named cors-mock-dev to match the existing cors-proxy-dev / cors-web-dev; a -dev→prod rename is a separate cleanup.
  • README's Playground section + disclaimer updated (it still called the playground a Hoppscotch fork).

Summary by CodeRabbit

  • New Features

    • Added an in-browser CORS playground that lets you compare a direct browser request with a proxied request side by side.
    • The playground now supports method selection, custom headers, request bodies, and a copyable request snippet.
    • A new public test endpoint is available for trying blocked requests and verifying proxy behavior.
  • Bug Fixes

    • Updated local and hosted playground routing so the page loads consistently and uses the intended proxy target.

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).
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Added a new /playground page and client-side CORS tester that compares direct versus proxied requests, wired a mock test API and local proxy binding, and updated deploy, local, e2e, and README support.

Changes

Built-in playground and mock API

Layer / File(s) Summary
Page, proxy, and deploy wiring
web/cloudflare-env.d.ts, web/app/(home)/playground/page.tsx, web/next.config.mjs, workers/mock/wrangler.jsonc, .github/workflows/deploy.yml
Adds the /playground page entry, the proxy binding fallback, the mock worker route, and the deploy step while removing the old /playground rewrite.
Client playground flow
web/components/playground.tsx
Adds the client playground UI, direct/proxied fetch execution, request parsing, timed result capture, and response rendering.
Local and e2e checks
scripts/stack.sh, tests/e2e/playwright.config.ts, tests/e2e/playground.spec.ts
Sets the local proxy URL for the playground, waits for the /playground page during stack startup, and adds a Playwright project plus an end-to-end CORS test.
Playground docs
README.md
Replaces the playground and disclaimer text with the new built-in tester description and updated lineage wording.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: an in-app CORS playground plus a mock.cors.sh test API.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch playground

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

tests/e2e/playground.spec.ts

Oops! 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
Referenced from: /.eslintrc.js
at exportsNotFound (node:internal/modules/esm/resolve:310:10)
at packageExportsResolve (node:internal/modules/esm/resolve:658:9)
at resolveExports (node:internal/modules/cjs/loader:685:36)
at Module._findPath (node:internal/modules/cjs/loader:752:31)
at Module._resolveFilename (node:internal/modules/cjs/loader:1461:27)
at wrapResolveFilename (node:internal/modules/cjs/loader:1049:27)
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1094:12)
at require.resolve (node:internal/modules/helpers:171:31)
at Object.resolve (/node_modules/.pnpm/@eslint+eslintrc@3.3.5/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2447:46)
at ConfigArrayFactory._loadExtendedShareableConfig (/node_modules/.pnpm/@eslint+eslintrc@3.3.5/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3383:33)
(node:2) ESLintRCWarning: You are using an eslintrc configuration file, which is deprecated and support will be removed in v10.0.0. Please migrate to an eslint.config.js file. See https://eslint.org/docs/latest/use/configure/migration-guide for details. An eslintrc configuration file is used because you have the ESLINT_USE_FLAT_CONFIG environment variable set to false. If you want to use an eslint.config.js file, remove the environment variable. If you want to find the location of the eslintrc configuration file, use the --debug flag.
(Use node --trace-warnings ... to show where the warning was created)

tests/e2e/playwright.config.ts

Oops! 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
Referenced from: /.eslintrc.js
at exportsNotFound (node:internal/modules/esm/resolve:310:10)
at packageExportsResolve (node:internal/modules/esm/resolve:658:9)
at resolveExports (node:internal/modules/cjs/loader:685:36)
at Module._findPath (node:internal/modules/cjs/loader:752:31)
at Module._resolveFilename (node:internal/modules/cjs/loader:1461:27)
at wrapResolveFilename (node:internal/modules/cjs/loader:1049:27)
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1094:12)
at require.resolve (node:internal/modules/helpers:171:31)
at Object.resolve (/node_modules/.pnpm/@eslint+eslintrc@3.3.5/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2447:46)
at ConfigArrayFactory._loadExtendedShareableConfig (/node_modules/.pnpm/@eslint+eslintrc@3.3.5/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3383:33)
(node:2) ESLintRCWarning: You are using an eslintrc configuration file, which is deprecated and support will be removed in v10.0.0. Please migrate to an eslint.config.js file. See https://eslint.org/docs/latest/use/configure/migration-guide for details. An eslintrc configuration file is used because you have the ESLINT_USE_FLAT_CONFIG environment variable set to false. If you want to use an eslint.config.js file, remove the environment variable. If you want to find the location of the eslintrc configuration file, use the --debug flag.
(Use node --trace-warnings ... to show where the warning was created)


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 8bc1333 and a98b9f0.

📒 Files selected for processing (10)
  • .github/workflows/deploy.yml
  • README.md
  • scripts/stack.sh
  • tests/e2e/playground.spec.ts
  • tests/e2e/playwright.config.ts
  • web/app/(home)/playground/page.tsx
  • web/cloudflare-env.d.ts
  • web/components/playground.tsx
  • web/next.config.mjs
  • workers/mock/wrangler.jsonc
💤 Files with no reviewable changes (1)
  • web/next.config.mjs

Comment on lines +66 to +99
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),
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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.

Suggested change
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.

Comment on lines +114 to +137
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 } : {}) },
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

@softmarshmallow softmarshmallow merged commit 0f406df into main Jun 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant