From 104c5a778129198c73f98c09d7665560b64b4afc Mon Sep 17 00:00:00 2001 From: Alec Thomas <112640918+a-thomas-22@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:06:34 -0500 Subject: [PATCH] fix(redirects): manage Render routes in code via API (200-cap + wildcard consolidation) Render caps a static site at 200 routes, and its Blueprint MERGES routes and never deletes them, so the 215-route set could never sync cleanly. Move route management out of the Blueprint and into code applied via the Render API: - redirects.config.js stays the single source of truth (202 entries). - scripts/sync-render-routes.mjs consolidates to 116 routes (11 verified prefix-swap wildcard collapses + 94 individuals + SPA fallbacks, ordered redirects-before-rewrites, deduped) and PUTs them to Render (atomic replace, PUT /v1/services/{id}/routes), which deletes anything not in the list. - .github/workflows/render-routes.yml validates the set on every PR and applies it on push to main (needs RENDER_API_KEY + RENDER_SERVICE_ID secrets). - render.yaml keeps only service config (no routes block). - Remove the obsolete render.yaml splice script + redirects-in-sync workflow. Verified end-to-end on a throwaway Render service: the 116-route set PUT successfully and legacy URLs (including wildcard tails) return 301s. --- .github/workflows/redirects-in-sync.yml | 35 -- .github/workflows/render-routes.yml | 46 ++ package.json | 4 +- redirects.config.js | 9 +- render.yaml | 670 +----------------------- scripts/sync-render-redirects.mjs | 68 --- scripts/sync-render-routes.mjs | 162 ++++++ vocs.config.tsx | 4 +- 8 files changed, 225 insertions(+), 773 deletions(-) delete mode 100644 .github/workflows/redirects-in-sync.yml create mode 100644 .github/workflows/render-routes.yml delete mode 100644 scripts/sync-render-redirects.mjs create mode 100644 scripts/sync-render-routes.mjs diff --git a/.github/workflows/redirects-in-sync.yml b/.github/workflows/redirects-in-sync.yml deleted file mode 100644 index ff00160..0000000 --- a/.github/workflows/redirects-in-sync.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: redirects-in-sync - -# Guarantees render.yaml matches redirects.config.js (the single source of -# truth). Render reads render.yaml at the start of a deployment, so a commit -# that edits redirects.config.js without regenerating render.yaml would ship -# stale production redirects. This job regenerates the generated block and fails -# if it differs from what's committed. - -on: - pull_request: - push: - branches: [main] - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - # No `npm install` needed: the sync script uses only Node built-ins and - # imports the local redirects.config.js. - - name: Regenerate render.yaml from redirects.config.js - run: node scripts/sync-render-redirects.mjs - - - name: Fail if render.yaml is out of date - run: | - if [ -n "$(git status --porcelain render.yaml)" ]; then - echo "::error::render.yaml is out of sync with redirects.config.js. Run 'npm run sync-redirects' and commit the result." - git --no-pager diff render.yaml - exit 1 - fi - echo "render.yaml is in sync with redirects.config.js." diff --git a/.github/workflows/render-routes.yml b/.github/workflows/render-routes.yml new file mode 100644 index 0000000..fb1cc72 --- /dev/null +++ b/.github/workflows/render-routes.yml @@ -0,0 +1,46 @@ +name: render-routes + +# Routes for the Render static site are managed in code (redirects.config.js), +# not in the Blueprint — Render Blueprints merge routes, never delete them, and +# cap a service at 200 routes. This workflow validates the consolidated route +# list on every PR, and applies it to Render via the API on pushes to main. +# +# Requires two repo secrets for the apply step: +# RENDER_API_KEY - a Render API key (Account Settings -> API Keys) +# RENDER_SERVICE_ID - the prod "docs" service id (srv-...) + +on: + pull_request: + paths: + - redirects.config.js + - scripts/sync-render-routes.mjs + - .github/workflows/render-routes.yml + push: + branches: [main] + paths: + - redirects.config.js + - scripts/sync-render-routes.mjs + - .github/workflows/render-routes.yml + +jobs: + routes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + + # Build + validate. No secrets needed: uses only Node built-ins and the + # local redirects.config.js. Fails if the consolidated set exceeds Render's + # 200-route cap or has duplicate sources. + - name: Validate routes + run: node scripts/sync-render-routes.mjs + + # Apply to Render only on pushes to main. + - name: Apply routes to Render + if: github.ref == 'refs/heads/main' + env: + RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} + RENDER_SERVICE_ID: ${{ secrets.RENDER_SERVICE_ID }} + run: node scripts/sync-render-routes.mjs --apply diff --git a/package.json b/package.json index e417e9f..626b94f 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,10 @@ "type": "module", "scripts": { "dev": "vocs dev", - "sync-redirects": "node scripts/sync-render-redirects.mjs", - "prebuild": "node scripts/sync-render-redirects.mjs", "build": "vocs build", "preview": "vocs preview", + "routes:check": "node scripts/sync-render-routes.mjs", + "routes:apply": "node scripts/sync-render-routes.mjs --apply", "format": "prettier --write ." }, "dependencies": { diff --git a/redirects.config.js b/redirects.config.js index 8c285d0..dbe57df 100644 --- a/redirects.config.js +++ b/redirects.config.js @@ -3,10 +3,13 @@ * * Single source of truth for redirects. Consumed in two places: * - the dev-server redirect middleware in vocs.config.tsx (local `vocs dev`) - * - `npm run sync-redirects`, which splices these into render.yaml so Render - * serves them in production (Vocs has no build-time redirect support). + * - scripts/sync-render-routes.mjs, which consolidates these (collapsing + * prefix groups into wildcard rules to fit Render's 200-route cap) and + * applies them to the Render static site via the API. Routes are NOT in + * render.yaml — Render Blueprints merge routes and never delete them. * - * After editing, run `npm run sync-redirects` and commit the updated render.yaml. + * To change redirects: edit this file and open a PR. CI validates the resulting + * route count (<=200); merging to main applies them to Render. * * Follows the OffchainLabs/arbitrum-docs redirects.config.js pattern. Kept as a * `.js` module (types in redirects.config.d.ts) so it resolves natively in both diff --git a/render.yaml b/render.yaml index 865fd43..eb64d74 100644 --- a/render.yaml +++ b/render.yaml @@ -1,15 +1,11 @@ -# Render Blueprint — adopts the existing, manually-created "docs" static site. +# Render Blueprint for the production "docs" static site. # -# Adoption is by `name`: it MUST stay "docs" to match the live service, or Render -# will spin up a suffixed duplicate instead of updating the real one. The service -# settings below mirror the Render dashboard export. -# -# Routing note: Render applies the FIRST matching rule by priority, top to bottom -# (https://render.com/docs/redirects-rewrites). The generated specific redirects -# MUST therefore stay ABOVE the broad SPA-fallback rewrites at the bottom — else -# `/sdk/*`, `/react/*`, `/smart-wallet/*` etc. would swallow ~145 legacy URLs into -# the app shell (which then 404s). The redirect block is generated from -# redirects.config.js by `npm run sync-redirects` — edit that file, not this one. +# NOTE: redirect/rewrite routes are intentionally NOT defined here. Render +# Blueprints MERGE routes and never delete them, and cap a service at 200 routes +# (https://render.com/docs/blueprint-spec). A curated redirect set needs +# deletions and must stay under the cap, so routes are managed in code via +# redirects.config.js and applied with the Render API by +# scripts/sync-render-routes.mjs (run in CI — see .github/workflows/render-routes.yml). version: "1" services: - type: web @@ -29,655 +25,3 @@ services: - docs.zerodev.app - new-docs.zerodev.app autoDeployTrigger: commit - routes: - # >>> BEGIN GENERATED REDIRECTS — produced by `npm run sync-redirects`; do not edit by hand - - type: redirect - source: "/sdk/getting-started/quickstart" - destination: "/get-started/quickstart" - - type: redirect - source: "/sdk/getting-started/tutorial" - destination: "/get-started/quickstart" - - type: redirect - source: "/sdk/getting-started/tutorial-passkeys" - destination: "/onboarding/passkeys/tutorial" - - type: redirect - source: "/sdk/getting-started/quickstart-7702" - destination: "/get-started/eip-7702/quickstart" - - type: redirect - source: "/sdk/getting-started/quickstart-agentkit" - destination: "/smart-accounts/permissions/agentkit" - - type: redirect - source: "/sdk/getting-started/migration" - destination: "/advanced/migration" - - type: redirect - source: "/sdk/core-api/create-account" - destination: "/onboarding/create-a-smart-account" - - type: redirect - source: "/sdk/core-api/using-plugins" - destination: "/smart-accounts/use-plugins/overview" - - type: redirect - source: "/sdk/core-api/send-transactions" - destination: "/smart-accounts/send-transactions" - - type: redirect - source: "/sdk/core-api/batch-transactions" - destination: "/smart-accounts/batch-transactions" - - type: redirect - source: "/sdk/core-api/sponsor-gas" - destination: "/smart-accounts/sponsor-gas/evm" - - type: redirect - source: "/sdk/core-api/pay-gas-with-erc20s" - destination: "/smart-accounts/pay-gas-with-erc20s" - - type: redirect - source: "/sdk/core-api/sign-and-verify" - destination: "/smart-accounts/sign-and-verify" - - type: redirect - source: "/sdk/core-api/deploy-contract" - destination: "/smart-accounts/deploy-contract" - - type: redirect - source: "/sdk/core-api/delegatecall" - destination: "/smart-accounts/delegatecall" - - type: redirect - source: "/sdk/core-api/status" - destination: "/api-and-toolings/tools/status" - - type: redirect - source: "/sdk/core-api/debugger" - destination: "/api-and-toolings/tools/debugger" - - type: redirect - source: "/sdk/advanced/chain-abstraction" - destination: "/smart-accounts/chain-abstraction/overview" - - type: redirect - source: "/sdk/advanced/passkeys" - destination: "/onboarding/passkeys/overview" - - type: redirect - source: "/sdk/advanced/multisig" - destination: "/advanced/multisig" - - type: redirect - source: "/sdk/advanced/social-login" - destination: "/onboarding/social-login" - - type: redirect - source: "/sdk/advanced/session-keys" - destination: "/smart-accounts/permissions/session-keys" - - type: redirect - source: "/sdk/advanced/recovery" - destination: "/advanced/account-recovery/sdk-recovery" - - type: redirect - source: "/sdk/advanced/multi-chain-signing" - destination: "/smart-accounts/multi-chain-signing" - - type: redirect - source: "/sdk/advanced/key-storage" - destination: "/advanced/key-storage" - - type: redirect - source: "/sdk/advanced/defi" - destination: "/smart-accounts/defi" - - type: redirect - source: "/sdk/advanced/parallel-orders" - destination: "/smart-accounts/parallel-transactions" - - type: redirect - source: "/sdk/advanced/wallet-connect" - destination: "/advanced/wallet-connect" - - type: redirect - source: "/sdk/advanced/fallback-providers" - destination: "/advanced/fallback-providers" - - type: redirect - source: "/sdk/advanced/run-solidity-code-on-init" - destination: "/advanced/track-deployed-accounts" - - type: redirect - source: "/sdk/advanced/upgrade-kernel" - destination: "/advanced/upgrade-kernel" - - type: redirect - source: "/sdk/advanced/go-sdk" - destination: "/get-started/sdks/server-side/go" - - type: redirect - source: "/advanced/go-sdk" - destination: "/get-started/sdks/server-side/go" - - type: redirect - source: "/sdk/advanced/userop-builder-api" - destination: "/advanced/userop-builder-api" - - type: redirect - source: "/sdk/advanced/supported-base-tokens" - destination: "/smart-accounts/chain-abstraction/supported-base-tokens" - - type: redirect - source: "/sdk/advanced/supported-defi-tokens" - destination: "/smart-accounts/chain-abstraction/supported-defi-tokens" - - type: redirect - source: "/sdk/permissions/intro" - destination: "/smart-accounts/permissions/intro" - - type: redirect - source: "/sdk/permissions/transaction-automation" - destination: "/smart-accounts/permissions/transaction-automation" - - type: redirect - source: "/sdk/permissions/install-with-init-config" - destination: "/smart-accounts/permissions/install-with-init-config" - - type: redirect - source: "/sdk/permissions/1-click-trading" - destination: "/smart-accounts/permissions/1-click-trading" - - type: redirect - source: "/sdk/permissions/signers/ecdsa" - destination: "/smart-accounts/permissions/signers/ecdsa" - - type: redirect - source: "/sdk/permissions/signers/passkeys" - destination: "/smart-accounts/permissions/signers/passkeys" - - type: redirect - source: "/sdk/permissions/signers/multisig" - destination: "/smart-accounts/permissions/signers/multisig" - - type: redirect - source: "/sdk/permissions/signers/build-your-own" - destination: "/smart-accounts/permissions/signers/build-your-own" - - type: redirect - source: "/sdk/permissions/policies/sudo" - destination: "/smart-accounts/permissions/policies/sudo" - - type: redirect - source: "/sdk/permissions/policies/call" - destination: "/smart-accounts/permissions/policies/call" - - type: redirect - source: "/sdk/permissions/policies/gas" - destination: "/smart-accounts/permissions/policies/gas" - - type: redirect - source: "/sdk/permissions/policies/signature" - destination: "/smart-accounts/permissions/policies/signature" - - type: redirect - source: "/sdk/permissions/policies/rate-limit" - destination: "/smart-accounts/permissions/policies/rate-limit" - - type: redirect - source: "/sdk/permissions/policies/timestamp" - destination: "/smart-accounts/permissions/policies/timestamp" - - type: redirect - source: "/sdk/permissions/policies/build-your-own" - destination: "/smart-accounts/permissions/policies/build-your-own" - - type: redirect - source: "/sdk/permissions/actions/build-your-own" - destination: "/smart-accounts/permissions/actions/build-your-own" - - type: redirect - source: "/sdk/signers/intro" - destination: "/onboarding/auth-providers" - - type: redirect - source: "/sdk/signers/dynamic" - destination: "/onboarding/dynamic" - - type: redirect - source: "/sdk/signers/privy" - destination: "/onboarding/privy" - - type: redirect - source: "/sdk/signers/magic" - destination: "/onboarding/magic" - - type: redirect - source: "/sdk/signers/web3auth" - destination: "/onboarding/web3auth" - - type: redirect - source: "/sdk/signers/smart-wallet" - destination: "/onboarding/smart-wallet" - - type: redirect - source: "/sdk/signers/portal" - destination: "/onboarding/portal" - - type: redirect - source: "/sdk/signers/turnkey" - destination: "/onboarding/turnkey" - - type: redirect - source: "/sdk/signers/fireblocks" - destination: "/onboarding/fireblocks" - - type: redirect - source: "/sdk/signers/capsule" - destination: "/onboarding/capsule" - - type: redirect - source: "/sdk/signers/lit-protocol" - destination: "/onboarding/lit-protocol" - - type: redirect - source: "/sdk/signers/particle" - destination: "/onboarding/particle" - - type: redirect - source: "/sdk/signers/dfns" - destination: "/onboarding/dfns" - - type: redirect - source: "/sdk/signers/arcana" - destination: "/onboarding/arcana" - - type: redirect - source: "/sdk/signers/eoa" - destination: "/onboarding/eoa" - - type: redirect - source: "/sdk/signers/custom-signer" - destination: "/onboarding/custom-signer" - - type: redirect - source: "/sdk/solana/sponsor-gas" - destination: "/smart-accounts/sponsor-gas/solana" - - type: redirect - source: "/sdk/infra/intro" - destination: "/api-and-toolings/infrastructure/choose-an-infra-provider" - - type: redirect - source: "/sdk/infra/zerodev" - destination: "/api-and-toolings/infrastructure/zerodev" - - type: redirect - source: "/sdk/infra/pimlico" - destination: "/api-and-toolings/infrastructure/pimlico" - - type: redirect - source: "/sdk/infra/coinbase" - destination: "/api-and-toolings/infrastructure/coinbase" - - type: redirect - source: "/sdk/presets/intro" - destination: "/api-and-toolings/presets/intro" - - type: redirect - source: "/sdk/presets/zerodev" - destination: "/api-and-toolings/presets/zerodev" - - type: redirect - source: "/sdk/faqs/chains" - destination: "/api-and-toolings/faqs/chains" - - type: redirect - source: "/sdk/faqs/audits" - destination: "/api-and-toolings/faqs/audits" - - type: redirect - source: "/sdk/faqs/debug-userop" - destination: "/api-and-toolings/faqs/debug-userop" - - type: redirect - source: "/sdk/faqs/use-with-ethers" - destination: "/api-and-toolings/faqs/use-with-ethers" - - type: redirect - source: "/sdk/faqs/use-with-gelato" - destination: "/api-and-toolings/faqs/use-with-gelato" - - type: redirect - source: "/sdk/faqs/use-with-react-native" - destination: "/api-and-toolings/faqs/use-with-react-native" - - type: redirect - source: "/meta-infra/intro" - destination: "/api-and-toolings/infrastructure/intro" - - type: redirect - source: "/meta-infra/gas-policies" - destination: "/api-and-toolings/infrastructure/gas-policies" - - type: redirect - source: "/meta-infra/custom-gas-policies" - destination: "/api-and-toolings/infrastructure/custom-gas-policies" - - type: redirect - source: "/meta-infra/rpcs" - destination: "/api-and-toolings/infrastructure/rpcs" - - type: redirect - source: "/meta-infra/api" - destination: "/api-and-toolings/infrastructure/api" - - type: redirect - source: "/recovery-flow/intro" - destination: "/advanced/account-recovery/flow-intro" - - type: redirect - source: "/recovery-flow/setup" - destination: "/advanced/account-recovery/flow-setup" - - type: redirect - source: "/recovery-flow/portal" - destination: "/advanced/account-recovery/portal" - - type: redirect - source: "/smart-routing-address" - destination: "/onramp/smart-routing-address" - - type: redirect - source: "/global-address" - destination: "/onramp/smart-routing-address" - - type: redirect - source: "/magic-account" - destination: "/smart-accounts/chain-abstraction/overview" - - type: redirect - source: "/react/getting-started" - destination: "/advanced/react-hooks/getting-started" - - type: redirect - source: "/react/use-balance" - destination: "/advanced/react-hooks/use-balance" - - type: redirect - source: "/react/use-chainid" - destination: "/advanced/react-hooks/use-chainid" - - type: redirect - source: "/react/use-chains" - destination: "/advanced/react-hooks/use-chains" - - type: redirect - source: "/react/use-create-basic-session" - destination: "/advanced/react-hooks/use-create-basic-session" - - type: redirect - source: "/react/use-create-kernelclient-eoa" - destination: "/advanced/react-hooks/use-create-kernelclient-eoa" - - type: redirect - source: "/react/use-create-kernelclient-passkey" - destination: "/advanced/react-hooks/use-create-kernelclient-passkey" - - type: redirect - source: "/react/use-create-kernelclient-social" - destination: "/advanced/react-hooks/use-create-kernelclient-social" - - type: redirect - source: "/react/use-create-session" - destination: "/advanced/react-hooks/use-create-session" - - type: redirect - source: "/react/use-disconnect-kernelclient" - destination: "/advanced/react-hooks/use-disconnect-kernelclient" - - type: redirect - source: "/react/use-kernelclient" - destination: "/advanced/react-hooks/use-kernelclient" - - type: redirect - source: "/react/use-send-transaction" - destination: "/advanced/react-hooks/use-send-transaction" - - type: redirect - source: "/react/use-send-transaction-with-session" - destination: "/advanced/react-hooks/use-send-transaction-with-session" - - type: redirect - source: "/react/use-send-useroperation" - destination: "/advanced/react-hooks/use-send-useroperation" - - type: redirect - source: "/react/use-send-useroperation-with-session" - destination: "/advanced/react-hooks/use-send-useroperation-with-session" - - type: redirect - source: "/react/use-sessions" - destination: "/advanced/react-hooks/use-sessions" - - type: redirect - source: "/react/use-session-kernelclient" - destination: "/advanced/react-hooks/use-session-kernelclient" - - type: redirect - source: "/react/use-set-kernelclient" - destination: "/advanced/react-hooks/use-set-kernelclient" - - type: redirect - source: "/react/use-switch-chain" - destination: "/advanced/react-hooks/use-switch-chain" - - type: redirect - source: "/react/use-wallet-connect" - destination: "/advanced/react-hooks/use-wallet-connect" - - type: redirect - source: "/smart-wallet/which-sdk" - destination: "/onboarding/create-a-smart-account" - - type: redirect - source: "/smart-wallet/quickstart-core" - destination: "/get-started/quickstart" - - type: redirect - source: "/smart-wallet/quickstart-react" - destination: "/get-started/quickstart" - - type: redirect - source: "/smart-wallet/quickstart-capabilities" - destination: "/get-started/quickstart" - - type: redirect - source: "/smart-wallet/creating-wallets" - destination: "/onboarding/create-a-smart-account" - - type: redirect - source: "/smart-wallet/setting-up-zerodev-projects" - destination: "/" - - type: redirect - source: "/smart-wallet/sending-transactions" - destination: "/smart-accounts/send-transactions" - - type: redirect - source: "/smart-wallet/batching-transactions" - destination: "/smart-accounts/batch-transactions" - - type: redirect - source: "/smart-wallet/pay-gas-in-erc20s" - destination: "/smart-accounts/pay-gas-with-erc20s" - - type: redirect - source: "/smart-wallet/sponsoring-gas" - destination: "/smart-accounts/sponsor-gas/evm" - - type: redirect - source: "/smart-wallet/delegatecall" - destination: "/smart-accounts/delegatecall" - - type: redirect - source: "/smart-wallet/multisig" - destination: "/advanced/multisig" - - type: redirect - source: "/smart-wallet/account-recovery" - destination: "/advanced/account-recovery/sdk-recovery" - - type: redirect - source: "/smart-wallet/importing-assets" - destination: "/onboarding/create-a-smart-account" - - type: redirect - source: "/smart-wallet/defi-integrations" - destination: "/smart-accounts/defi" - - type: redirect - source: "/smart-wallet/one-click-trading" - destination: "/smart-accounts/permissions/transaction-automation" - - type: redirect - source: "/smart-wallet/parallel-transactions" - destination: "/smart-accounts/parallel-transactions" - - type: redirect - source: "/smart-wallet/transaction-automation" - destination: "/smart-accounts/permissions/transaction-automation" - - type: redirect - source: "/smart-wallet/wallet-connect" - destination: "/advanced/wallet-connect" - - type: redirect - source: "/smart-wallet/infra-fallbacks" - destination: "/advanced/fallback-providers" - - type: redirect - source: "/smart-wallet/code-examples" - destination: "/" - - type: redirect - source: "/smart-wallet/permissions/intro" - destination: "/smart-accounts/permissions/intro" - - type: redirect - source: "/smart-wallet/permissions/transaction-automation" - destination: "/smart-accounts/permissions/transaction-automation" - - type: redirect - source: "/smart-wallet/permissions/signers/ecdsa" - destination: "/smart-accounts/permissions/signers/ecdsa" - - type: redirect - source: "/smart-wallet/permissions/signers/passkeys" - destination: "/smart-accounts/permissions/signers/passkeys" - - type: redirect - source: "/smart-wallet/permissions/signers/multisig" - destination: "/smart-accounts/permissions/signers/multisig" - - type: redirect - source: "/smart-wallet/permissions/signers/build-your-own" - destination: "/smart-accounts/permissions/signers/build-your-own" - - type: redirect - source: "/smart-wallet/permissions/policies/sudo" - destination: "/smart-accounts/permissions/policies/sudo" - - type: redirect - source: "/smart-wallet/permissions/policies/call" - destination: "/smart-accounts/permissions/policies/call" - - type: redirect - source: "/smart-wallet/permissions/policies/gas" - destination: "/smart-accounts/permissions/policies/gas" - - type: redirect - source: "/smart-wallet/permissions/policies/signature" - destination: "/smart-accounts/permissions/policies/signature" - - type: redirect - source: "/smart-wallet/permissions/policies/rate-limit" - destination: "/smart-accounts/permissions/policies/rate-limit" - - type: redirect - source: "/smart-wallet/permissions/policies/timestamp" - destination: "/smart-accounts/permissions/policies/timestamp" - - type: redirect - source: "/smart-wallet/permissions/policies/build-your-own" - destination: "/smart-accounts/permissions/policies/build-your-own" - - type: redirect - source: "/smart-wallet/permissions/actions/build-your-own" - destination: "/smart-accounts/permissions/actions/build-your-own" - - type: redirect - source: "/smart-wallet/permissions/1-click-trading" - destination: "/smart-accounts/permissions/1-click-trading" - - type: redirect - source: "/smart-accounts/create-a-smart-account" - destination: "/onboarding/create-a-smart-account" - - type: redirect - source: "/smart-accounts/authentication/social-login" - destination: "/onboarding/social-login" - - type: redirect - source: "/smart-accounts/authentication/eoa" - destination: "/onboarding/eoa" - - type: redirect - source: "/smart-accounts/authentication/custom-signer" - destination: "/onboarding/custom-signer" - - type: redirect - source: "/smart-accounts/authentication/dynamic" - destination: "/onboarding/dynamic" - - type: redirect - source: "/smart-accounts/authentication/privy" - destination: "/onboarding/privy" - - type: redirect - source: "/smart-accounts/authentication/magic" - destination: "/onboarding/magic" - - type: redirect - source: "/smart-accounts/authentication/web3auth" - destination: "/onboarding/web3auth" - - type: redirect - source: "/smart-accounts/authentication/particle" - destination: "/onboarding/particle" - - type: redirect - source: "/smart-accounts/authentication/arcana" - destination: "/onboarding/arcana" - - type: redirect - source: "/smart-accounts/authentication/turnkey" - destination: "/onboarding/turnkey" - - type: redirect - source: "/smart-accounts/authentication/fireblocks" - destination: "/onboarding/fireblocks" - - type: redirect - source: "/smart-accounts/authentication/dfns" - destination: "/onboarding/dfns" - - type: redirect - source: "/smart-accounts/authentication/lit-protocol" - destination: "/onboarding/lit-protocol" - - type: redirect - source: "/smart-accounts/authentication/capsule" - destination: "/onboarding/capsule" - - type: redirect - source: "/smart-accounts/authentication/portal" - destination: "/onboarding/portal" - - type: redirect - source: "/smart-accounts/authentication/smart-wallet" - destination: "/onboarding/smart-wallet" - - type: redirect - source: "/smart-accounts/use-plugins/signers-intro" - destination: "/onboarding/auth-providers" - - type: redirect - source: "/smart-accounts/use-plugins/passkeys/overview" - destination: "/onboarding/passkeys/overview" - - type: redirect - source: "/smart-accounts/use-plugins/passkeys/tutorial" - destination: "/onboarding/passkeys/tutorial" - - type: redirect - source: "/smart-accounts/use-plugins/multisig" - destination: "/advanced/multisig" - - type: redirect - source: "/smart-accounts/account-recovery/sdk-recovery" - destination: "/advanced/account-recovery/sdk-recovery" - - type: redirect - source: "/smart-accounts/account-recovery/flow-intro" - destination: "/advanced/account-recovery/flow-intro" - - type: redirect - source: "/smart-accounts/account-recovery/flow-setup" - destination: "/advanced/account-recovery/flow-setup" - - type: redirect - source: "/smart-accounts/account-recovery/portal" - destination: "/advanced/account-recovery/portal" - - type: redirect - source: "/smart-accounts/eip-7702/quickstart" - destination: "/get-started/eip-7702/quickstart" - - type: redirect - source: "/cross-chain/smart-routing-address" - destination: "/onramp/smart-routing-address" - - type: redirect - source: "/cross-chain/chain-abstraction/overview" - destination: "/smart-accounts/chain-abstraction/overview" - - type: redirect - source: "/cross-chain/chain-abstraction/supported-base-tokens" - destination: "/smart-accounts/chain-abstraction/supported-base-tokens" - - type: redirect - source: "/cross-chain/chain-abstraction/supported-defi-tokens" - destination: "/smart-accounts/chain-abstraction/supported-defi-tokens" - - type: redirect - source: "/advanced/defi" - destination: "/smart-accounts/defi" - - type: redirect - source: "/advanced/multi-chain-signing" - destination: "/smart-accounts/multi-chain-signing" - - type: redirect - source: "/advanced/parallel-transactions" - destination: "/smart-accounts/parallel-transactions" - - type: redirect - source: "/advanced/deploy-contract" - destination: "/smart-accounts/deploy-contract" - - type: redirect - source: "/advanced/delegatecall" - destination: "/smart-accounts/delegatecall" - - type: redirect - source: "/resources/infrastructure/intro" - destination: "/api-and-toolings/infrastructure/intro" - - type: redirect - source: "/resources/infrastructure/gas-policies" - destination: "/api-and-toolings/infrastructure/gas-policies" - - type: redirect - source: "/resources/infrastructure/custom-gas-policies" - destination: "/api-and-toolings/infrastructure/custom-gas-policies" - - type: redirect - source: "/resources/infrastructure/rpcs" - destination: "/api-and-toolings/infrastructure/rpcs" - - type: redirect - source: "/resources/infrastructure/api" - destination: "/api-and-toolings/infrastructure/api" - - type: redirect - source: "/resources/infrastructure/choose-an-infra-provider" - destination: "/api-and-toolings/infrastructure/choose-an-infra-provider" - - type: redirect - source: "/resources/infrastructure/zerodev" - destination: "/api-and-toolings/infrastructure/zerodev" - - type: redirect - source: "/resources/infrastructure/pimlico" - destination: "/api-and-toolings/infrastructure/pimlico" - - type: redirect - source: "/resources/infrastructure/coinbase" - destination: "/api-and-toolings/infrastructure/coinbase" - - type: redirect - source: "/resources/presets/intro" - destination: "/api-and-toolings/presets/intro" - - type: redirect - source: "/resources/presets/zerodev" - destination: "/api-and-toolings/presets/zerodev" - - type: redirect - source: "/resources/tools/status" - destination: "/api-and-toolings/tools/status" - - type: redirect - source: "/resources/tools/debugger" - destination: "/api-and-toolings/tools/debugger" - - type: redirect - source: "/resources/faqs/chains" - destination: "/api-and-toolings/faqs/chains" - - type: redirect - source: "/resources/faqs/audits" - destination: "/api-and-toolings/faqs/audits" - - type: redirect - source: "/resources/faqs/debug-userop" - destination: "/api-and-toolings/faqs/debug-userop" - - type: redirect - source: "/resources/faqs/use-with-ethers" - destination: "/api-and-toolings/faqs/use-with-ethers" - - type: redirect - source: "/resources/faqs/use-with-gelato" - destination: "/api-and-toolings/faqs/use-with-gelato" - - type: redirect - source: "/resources/faqs/use-with-react-native" - destination: "/api-and-toolings/faqs/use-with-react-native" - # <<< END GENERATED REDIRECTS - # ---- hand-maintained; keep BELOW the generated redirects (first match wins) ---- - # Legacy Magic Account subpaths (the exact /magic-account is in the block above). - - type: redirect - source: "/magic-account/*" - destination: "/smart-accounts/chain-abstraction/overview" - # SPA fallbacks: serve the app shell for any unmapped legacy path under these - # prefixes so it gets the in-app 404 rather than Render's raw 404. Keep last. - - type: rewrite - source: "/sdk/*" - destination: "/index.html" - - type: rewrite - source: "/sdk" - destination: "/index.html" - - type: rewrite - source: "/meta-infra/*" - destination: "/index.html" - - type: rewrite - source: "/meta-infra" - destination: "/index.html" - - type: rewrite - source: "/recovery-flow/*" - destination: "/index.html" - - type: rewrite - source: "/recovery-flow" - destination: "/index.html" - - type: rewrite - source: "/blog/*" - destination: "/index.html" - - type: rewrite - source: "/blog" - destination: "/index.html" - - type: rewrite - source: "/react/*" - destination: "/index.html" - - type: rewrite - source: "/react" - destination: "/index.html" - - type: rewrite - source: "/smart-wallet/*" - destination: "/index.html" - - type: rewrite - source: "/smart-wallet" - destination: "/index.html" diff --git a/scripts/sync-render-redirects.mjs b/scripts/sync-render-redirects.mjs deleted file mode 100644 index b6cc242..0000000 --- a/scripts/sync-render-redirects.mjs +++ /dev/null @@ -1,68 +0,0 @@ -/** - * sync-render-redirects — splice redirects.config.js into render.yaml. - * - * The list in redirects.config.js is the single source of truth. Vocs only - * applies it in local dev (via the middleware in vocs.config.tsx), so this - * script mirrors it into render.yaml so Render serves the redirects in - * production. Render reads render.yaml at the start of a deployment (before the - * build command runs), so the generated file is committed. - * - * render.yaml is hand-maintained: it holds the static-site service definition - * (name/build/domains) and the broad SPA-fallback rewrites that must sit BELOW - * the redirects. So rather than regenerating the whole file, we replace only the - * lines between the BEGIN/END markers, leaving the rest untouched. This mirrors - * the OffchainLabs/arbitrum-docs sentinel-splice approach. - * - * Run with `npm run sync-redirects` (also runs on `npm run build` via prebuild). - */ - -import { readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { redirects } from "../redirects.config.js"; - -const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); -const renderPath = path.join(root, "render.yaml"); - -const BEGIN = "# >>> BEGIN GENERATED REDIRECTS"; -const END = "# <<< END GENERATED REDIRECTS"; - -const yaml = await readFile(renderPath, "utf8"); -const lines = yaml.split("\n"); - -const beginIdx = lines.findIndex((l) => l.includes(BEGIN)); -const endIdx = lines.findIndex((l) => l.includes(END)); -if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) { - throw new Error( - `sync-render-redirects: could not find BEGIN/END markers in render.yaml`, - ); -} - -// Indent the generated list items to match the BEGIN marker's column. Render -// redirects are always 301 (the type isn't configurable), so there's no -// permanent/temporary flag to emit. Values are double-quoted (JSON.stringify) -// so any future path with YAML-special characters stays safe. -// -// Format note: block style + double-quoted scalars is exactly Prettier's -// default YAML output, so the generated render.yaml stays prettier-clean (no -// .prettierignore needed, `npm run format` leaves it untouched). Verified -// against prettier 3.5.3–3.8.x. Don't switch this to flow style (`{ type: ..., -// source: ... }`) — Prettier would reformat it and fight the sync check. -const indent = lines[beginIdx].match(/^\s*/)[0]; -const block = redirects.flatMap(({ from, to }) => [ - `${indent}- type: redirect`, - `${indent} source: ${JSON.stringify(from)}`, - `${indent} destination: ${JSON.stringify(to)}`, -]); - -const next = [ - ...lines.slice(0, beginIdx + 1), - ...block, - ...lines.slice(endIdx), -]; - -await writeFile(renderPath, next.join("\n")); -console.log( - `sync-render-redirects: wrote ${redirects.length} redirects into render.yaml.`, -); diff --git a/scripts/sync-render-routes.mjs b/scripts/sync-render-routes.mjs new file mode 100644 index 0000000..b35569b --- /dev/null +++ b/scripts/sync-render-routes.mjs @@ -0,0 +1,162 @@ +/** + * sync-render-routes — apply production redirect/rewrite routes to the Render + * static site via the Render API. + * + * WHY NOT render.yaml / Blueprint? + * Render Blueprints MERGE routes and never delete them + * (https://render.com/docs/blueprint-spec), and a service is capped at + * 200 routes total. A curated redirect set needs deletions and must stay under + * the cap, so routes are owned here instead: `redirects.config.js` is the + * single source of truth, this script consolidates it to <=200 routes, and + * PUTs the whole list (atomic replace) to + * PUT https://api.render.com/v1/services/{id}/routes + * which deletes anything not in the list and sets priority = list order. + * + * The raw map has 200+ entries (over the cap), so prefix-preserving groups are + * collapsed into wildcard rules (`/old/* -> /new/*`, Render substitutes the + * captured tail). Only groups that are a clean prefix swap for EVERY member are + * collapsed; anything else stays an individual rule. See COLLAPSES below. + * + * Usage: + * node scripts/sync-render-routes.mjs # dry run: build + validate + print summary + * node scripts/sync-render-routes.mjs --apply # also PUT to Render + * # requires RENDER_API_KEY + RENDER_SERVICE_ID + * node scripts/sync-render-routes.mjs --json # print the resolved route list as JSON + */ + +import { redirects } from "../redirects.config.js"; + +const RENDER_ROUTE_LIMIT = 200; + +// Prefix-swap collapses: every legacy path under `from` maps to `to` + the same +// trailing segments, so the whole group becomes one wildcard rule. Each is +// verified against redirects.config.js at build time — if any member is NOT a +// clean swap, the collapse is skipped and its entries stay individual (and the +// build will warn). Keep these CONSERVATIVE; do not add a prefix whose leaves +// scatter to different destinations (it would mis-redirect via the wildcard). +const COLLAPSES = [ + ["/react", "/advanced/react-hooks"], + ["/resources", "/api-and-toolings"], + ["/sdk/permissions", "/smart-accounts/permissions"], + ["/smart-accounts/authentication", "/onboarding"], + ["/smart-wallet/permissions", "/smart-accounts/permissions"], + ["/sdk/faqs", "/api-and-toolings/faqs"], + ["/meta-infra", "/api-and-toolings/infrastructure"], + ["/smart-accounts/account-recovery", "/advanced/account-recovery"], + ["/cross-chain/chain-abstraction", "/smart-accounts/chain-abstraction"], + ["/sdk/presets", "/api-and-toolings/presets"], + ["/smart-accounts/use-plugins/passkeys", "/onboarding/passkeys"], +]; + +// Legacy Magic Account subpaths (no per-path entries in redirects.config.js). +const MAGIC_ACCOUNT_WILDCARD = { + type: "redirect", + source: "/magic-account/*", + destination: "/smart-accounts/chain-abstraction/overview", +}; + +// SPA fallbacks: serve the app shell for unmapped legacy paths under these +// prefixes (so they get the in-app 404, not Render's raw 404). Emitted as both +// `/prefix/*` and `/prefix`. Any that collide with a redirect source above are +// dropped (the redirect wins) during de-duplication. +const SPA_PREFIXES = ["/sdk", "/meta-infra", "/recovery-flow", "/blog", "/react", "/smart-wallet"]; + +export function buildRoutes() { + const warnings = []; + const consumed = new Set(); + const wildcards = []; + + for (const [src, dst] of COLLAPSES) { + const group = redirects.filter((r) => r.from === src || r.from.startsWith(src + "/")); + if (!group.length) { + warnings.push(`collapse ${src}: no matching entries`); + continue; + } + const bad = group.filter((r) => r.to !== dst + r.from.slice(src.length)); + if (bad.length) { + warnings.push( + `collapse ${src} -> ${dst} SKIPPED: ${bad.length} non-clean member(s), e.g. ${bad[0].from} -> ${bad[0].to}`, + ); + continue; + } + group.forEach((r) => consumed.add(r.from)); + wildcards.push({ type: "redirect", source: `${src}/*`, destination: `${dst}/*` }); + } + + const individuals = redirects + .filter((r) => !consumed.has(r.from)) + .map((r) => ({ type: "redirect", source: r.from, destination: r.to })); + + const spaRewrites = SPA_PREFIXES.flatMap((p) => [ + { type: "rewrite", source: `${p}/*`, destination: "/index.html" }, + { type: "rewrite", source: p, destination: "/index.html" }, + ]); + + // Order matters (Render = first match by priority, top to bottom): + // specific redirects, then collapse wildcards, then the magic-account + // wildcard, then the broad SPA fallbacks last. + const ordered = [...individuals, ...wildcards, MAGIC_ACCOUNT_WILDCARD, ...spaRewrites]; + + // De-dup by source (first occurrence wins) so a broad SPA rewrite never + // shadows or duplicates a redirect with the same source (e.g. /react/*). + const seen = new Set(); + const routes = []; + for (const r of ordered) { + if (seen.has(r.source)) continue; + seen.add(r.source); + routes.push(r); + } + + const stats = { + sourceEntries: redirects.length, + individuals: individuals.length, + wildcards: wildcards.length, + rewrites: routes.filter((r) => r.type === "rewrite").length, + total: routes.length, + }; + + if (routes.length > RENDER_ROUTE_LIMIT) { + throw new Error( + `build produced ${routes.length} routes, over Render's ${RENDER_ROUTE_LIMIT} cap. ` + + `Add a safe collapse to COLLAPSES or trim redirects.config.js.`, + ); + } + return { routes, stats, warnings }; +} + +async function putRoutes(routes) { + const key = process.env.RENDER_API_KEY; + const svc = process.env.RENDER_SERVICE_ID; + if (!key || !svc) { + throw new Error("--apply requires RENDER_API_KEY and RENDER_SERVICE_ID env vars."); + } + const res = await fetch(`https://api.render.com/v1/services/${svc}/routes`, { + method: "PUT", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify(routes), + }); + const body = await res.text(); + if (!res.ok) { + throw new Error(`Render API PUT failed (HTTP ${res.status}): ${body}`); + } + return JSON.parse(body); +} + +// --- CLI --- +const { routes, stats, warnings } = buildRoutes(); + +if (process.argv.includes("--json")) { + process.stdout.write(JSON.stringify(routes, null, 2) + "\n"); +} else { + console.log( + `routes: ${stats.total}/${RENDER_ROUTE_LIMIT} ` + + `(${stats.individuals} individual + ${stats.wildcards} wildcard redirects, ${stats.rewrites} rewrites) ` + + `from ${stats.sourceEntries} source entries`, + ); + for (const w of warnings) console.warn(` warning: ${w}`); +} + +if (process.argv.includes("--apply")) { + const applied = await putRoutes(routes); + console.log(`applied: PUT replaced routes — service now has ${applied.length} routes.`); +} diff --git a/vocs.config.tsx b/vocs.config.tsx index 33119cd..e32b8df 100644 --- a/vocs.config.tsx +++ b/vocs.config.tsx @@ -5,8 +5,8 @@ import { redirects } from "./redirects.config.js"; dotenv.config(); // Lookup table for the dev-server redirect middleware below. The redirect list -// lives in redirects.config.js (single source of truth) and is spliced into -// render.yaml for production by `npm run sync-redirects`. +// lives in redirects.config.js (single source of truth); in production it is +// consolidated and applied to Render via the API by scripts/sync-render-routes.mjs. const REDIRECT_MAP: Record = Object.fromEntries( redirects.map((r) => [r.from, r.to]), );