diff --git a/.github/workflows/deploy-to-pages.yml b/.github/workflows/deploy-to-pages.yml new file mode 100644 index 000000000..4e4ebd514 --- /dev/null +++ b/.github/workflows/deploy-to-pages.yml @@ -0,0 +1,59 @@ +name: Deploy to GitHub Pages (Preview) + +on: + workflow_dispatch: # Only manual trigger + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22" + cache: "yarn" + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Clear Next.js build cache + run: rm -rf .next out + + - name: Build + run: yarn build + env: + NODE_ENV: production + BASE_PATH: /ar-io-docs + NEXT_PUBLIC_BASE_PATH: /ar-io-docs + NEXT_PUBLIC_SITE_URL: https://ar-io.github.io/ar-io-docs + + - name: Setup Pages + uses: actions/configure-pages@v6 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: ./out + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/pr-preview.yaml b/.github/workflows/pr-preview.yaml index cb1a541da..ddf906447 100644 --- a/.github/workflows/pr-preview.yaml +++ b/.github/workflows/pr-preview.yaml @@ -1,16 +1,11 @@ name: PR Preview Deployment on: - pull_request: - types: [opened, synchronize, reopened, closed] - paths: - - "content/**" - - "src/**" + workflow_dispatch: jobs: lint: runs-on: ubuntu-latest - if: github.event.action != 'closed' steps: - name: Checkout repository @@ -31,7 +26,6 @@ jobs: deploy-preview: runs-on: ubuntu-latest needs: lint - if: github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'closed' steps: - name: Checkout repository diff --git a/content/build/run-a-gateway/manage/environment-variables.mdx b/content/build/run-a-gateway/manage/environment-variables.mdx index 4663b815c..4326ffc18 100644 --- a/content/build/run-a-gateway/manage/environment-variables.mdx +++ b/content/build/run-a-gateway/manage/environment-variables.mdx @@ -382,6 +382,8 @@ The default public Solana RPC is rate-limited and may block `getProgramAccounts` The observer uploads report bundles to Turbo. The upload signer is resolved from the first matching env in the [precedence chain](/build/run-a-gateway/manage/solana-migration#upload-signing-precedence). Setting envs from more than one chain group at once is rejected at startup. +If your observer logs warn that `TurboReportSink` is not configured, explicitly set a Solana upload signer. Most operators can use the same base58 secret for both `OBSERVER_PRIVATE_KEY` and `SOLANA_UPLOAD_PRIVATE_KEY`. + | Variable | Type | Default | Description | | --------------------------------- | ------ | ------- | --------------------------------------------------------------------------- | | `ARWEAVE_UPLOAD_KEY_FILE` | string | - | Path to an Arweave JWK file. Highest priority for upload signing | @@ -391,7 +393,7 @@ The observer uploads report bundles to Turbo. The upload signer is resolved from | `SOLANA_UPLOAD_KEYPAIR_PATH` | string | - | Path to a separate Solana keypair JSON for uploads. Ignored when any `ARWEAVE_UPLOAD_*` or `ETHEREUM_UPLOAD_*` is set | | `SOLANA_UPLOAD_PRIVATE_KEY` | string | - | Alternative to above: base58 secret. Mutually exclusive with the file form | -When none of the above are set, uploads fall back to the observer key, then the operator key. +When none of the above are set, uploads fall back to the observer key, then the operator key. For production observers, prefer setting `SOLANA_UPLOAD_KEYPAIR_PATH` or `SOLANA_UPLOAD_PRIVATE_KEY` explicitly so report uploads do not depend on fallback behavior. ### Offset Observation diff --git a/content/build/run-a-gateway/manage/solana-migration.mdx b/content/build/run-a-gateway/manage/solana-migration.mdx index 672281458..689701002 100644 --- a/content/build/run-a-gateway/manage/solana-migration.mdx +++ b/content/build/run-a-gateway/manage/solana-migration.mdx @@ -120,7 +120,7 @@ Complete these steps before the cutover date to ensure uninterrupted reward elig - If you already have a Phantom-exported base58 secret string for the observer or operator, you can skip the JSON keypair file entirely: set `OBSERVER_PRIVATE_KEY=` (or `SOLANA_PRIVATE_KEY=`) instead of the `*_KEYPAIR_PATH` env. Setting both forms for the same role is rejected at startup. + If you already have a Phantom-exported base58 secret string for the observer or operator, you can skip the JSON keypair file entirely: set `OBSERVER_PRIVATE_KEY=` (or `SOLANA_PRIVATE_KEY=`) instead of the `*_KEYPAIR_PATH` env. When using an inline observer key, also set `SOLANA_UPLOAD_PRIVATE_KEY=` so observer report uploads to Turbo use the same signer. Setting both forms for the same role is rejected at startup. @@ -223,7 +223,7 @@ The gateway uses up to four distinct wallet roles. Understanding these helps you |---|---|---|---| | **Operator** (+ cranker) | `join_network`, `update_gateway_settings`, permissionless cranker instructions | `SOLANA_KEYPAIR_PATH` or `SOLANA_PRIVATE_KEY` | — (required) | | **Observer** | `save_observations` transactions | `OBSERVER_KEYPAIR_PATH` or `OBSERVER_PRIVATE_KEY` | Falls back to operator key | -| **Upload** | Observer report bundles sent to Turbo | See [upload precedence](#upload-signing-precedence) below | Falls back to observer → operator Solana key | +| **Upload** | Observer report bundles sent to Turbo | See [upload precedence](#upload-signing-precedence) below | Falls back to observer → operator Solana key, but explicit upload env is recommended | | **HTTPSIG signer** | RFC 9421 response headers | Uses observer Solana key when set | Auto-generated standalone Ed25519 key | @@ -247,12 +247,20 @@ These are the five supported wallet setups. **Pattern 1 is the recommended defau ```bash # One key for operator + observer + uploads SOLANA_KEYPAIR_PATH=/app/wallets/operator-keypair.json +SOLANA_UPLOAD_KEYPAIR_PATH=/app/wallets/operator-keypair.json SOLANA_RPC_URL= AR_IO_WALLET= OBSERVER_WALLET= ENABLE_EPOCH_CRANKING=false # flip to true when ready ``` +If you use a base58 private key instead of a keypair file, explicitly set the upload key too: + +```bash +OBSERVER_PRIVATE_KEY= +SOLANA_UPLOAD_PRIVATE_KEY= +``` + #### Pattern 2 — Keep existing Arweave JWK for uploads The most common path for operators migrating from a pre-Solana setup. Your existing Arweave JWK continues signing report bundles while the Solana keypair handles protocol interactions. @@ -275,14 +283,19 @@ The gateway picks the first matching upload signer from this list: 2. ARWEAVE_UPLOAD_JWK (inline) → ArweaveSigner 3. ETHEREUM_UPLOAD_PRIVATE_KEY_FILE (file) → EthereumSigner 4. ETHEREUM_UPLOAD_PRIVATE_KEY (inline) → EthereumSigner -5. SOLANA_UPLOAD_KEYPAIR_PATH (explicit) → SolanaSigner -6. Fallback: OBSERVER_KEYPAIR_PATH ?? SOLANA_KEYPAIR_PATH → SolanaSigner +5. SOLANA_UPLOAD_KEYPAIR_PATH (explicit file) → SolanaSigner +6. SOLANA_UPLOAD_PRIVATE_KEY (explicit inline) → SolanaSigner +7. Fallback: OBSERVER_KEYPAIR_PATH ?? SOLANA_KEYPAIR_PATH → SolanaSigner ``` Setting upload envs from more than one chain at once (e.g. `ARWEAVE_UPLOAD_KEY_FILE` **plus** `ETHEREUM_UPLOAD_PRIVATE_KEY`) raises a startup error listing every conflicting env. Pick exactly one upload chain. + +If observer logs warn that `TurboReportSink` is not configured, the observer does not have an upload signer for report data. Add `SOLANA_UPLOAD_PRIVATE_KEY=` or `SOLANA_UPLOAD_KEYPAIR_PATH=`, then restart the observer and check the next epoch logs. + + ### Key Formats Solana keypairs come in two common formats. Both encode the same 64-byte secret (`seed(32) || pubkey(32)`): diff --git a/content/build/run-a-gateway/quick-start.mdx b/content/build/run-a-gateway/quick-start.mdx index a2fa70d03..5b5595447 100644 --- a/content/build/run-a-gateway/quick-start.mdx +++ b/content/build/run-a-gateway/quick-start.mdx @@ -114,6 +114,8 @@ Ready to run a gateway with your own domain name and SSL certificates? Follow th ARNS_ROOT_HOST= AR_IO_WALLET= OBSERVER_WALLET= + OBSERVER_PRIVATE_KEY= + SOLANA_UPLOAD_PRIVATE_KEY= ``` @@ -123,8 +125,15 @@ Ready to run a gateway with your own domain name and SSL certificates? Follow th **Supply Observer Wallet Keyfile:** Save your Solana keypair JSON file as `.json` in the `wallets` directory. + If you use a keypair file instead of inline base58 keys, remove `OBSERVER_PRIVATE_KEY` and `SOLANA_UPLOAD_PRIVATE_KEY` from `.env`, then set both paths explicitly: + + ```bash + OBSERVER_KEYPAIR_PATH=/app/wallets/.json + SOLANA_UPLOAD_KEYPAIR_PATH=/app/wallets/.json + ``` + - By default, the Observer will use [Turbo Credits](https://docs.ardrive.io/docs/turbo/credits) to pay for uploading reports to Arweave. This allows reports under 100kb to be uploaded for free, but larger reports will fail if the Observer wallet does not contain Credits. Including `REPORT_DATA_SINK=arweave` in your `.env` file will configure the Observer to use AR tokens instead of Turbo Credits, without any free limit. + By default, the Observer will use [Turbo Credits](https://docs.ardrive.io/docs/turbo/credits) to pay for uploading reports to Arweave. This allows reports under 100kb to be uploaded for free, but larger reports will fail if the Observer wallet does not contain Credits. Set `SOLANA_UPLOAD_PRIVATE_KEY` to the same base58 secret as `OBSERVER_PRIVATE_KEY` so Turbo has an explicit upload signer. If logs warn that `TurboReportSink` is not configured, this upload key is usually missing. Including `REPORT_DATA_SINK=arweave` in your `.env` file will configure the Observer to use AR tokens instead of Turbo Credits, without any free limit. **Start the Docker container:** @@ -306,12 +315,21 @@ Ready to run a gateway with your own domain name and SSL certificates? Follow th ARNS_ROOT_HOST= AR_IO_WALLET= OBSERVER_WALLET= + OBSERVER_PRIVATE_KEY= + SOLANA_UPLOAD_PRIVATE_KEY= ``` **Save as `.env`** (select "All Files" as file type) **Supply Observer Wallet Keyfile:** Save your wallet keyfile as `.json` in the `wallets` directory. + + If you use a keypair file instead of inline base58 keys, remove `OBSERVER_PRIVATE_KEY` and `SOLANA_UPLOAD_PRIVATE_KEY` from `.env`, then set both paths explicitly: + + ```bash + OBSERVER_KEYPAIR_PATH=/app/wallets/.json + SOLANA_UPLOAD_KEYPAIR_PATH=/app/wallets/.json + ``` diff --git a/next.config.mjs b/next.config.mjs index 48c1e9ae6..3de65c0e3 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -8,6 +8,7 @@ const config = { // Enable static export only for production builds output: process.env.NODE_ENV === "production" ? "export" : "standalone", trailingSlash: process.env.NODE_ENV === "production" ? true : false, + basePath: process.env.BASE_PATH || "", reactStrictMode: true, eslint: { // Warning: This allows production builds to successfully complete even if diff --git a/package.json b/package.json index 387c4af68..520cc67b9 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "NODE_ENV=production next build", + "build": "NODE_ENV=production next build && tsx scripts/inject-chunk-load-recovery.ts", "dev": "next dev --turbo", "start": "next start", "lint": "eslint src/ content/ --ext .ts,.tsx,.mdx", @@ -14,6 +14,7 @@ "generate-sdk-llm-texts": "node scripts/generate-sdk-llm-texts.js", "generate-all-docs": "npm run generate-sdk-docs && npm run generate-llm-text && npm run generate-sdk-llm-texts", "check-links": "node scripts/check-links.mjs", + "test:chunk-recovery": "node tests/chunk-load-recovery.test.mjs", "test-arns": "node scripts/test-arns-update.js", "test-signer": "node scripts/test-signer-only.js" }, diff --git a/scripts/inject-chunk-load-recovery.ts b/scripts/inject-chunk-load-recovery.ts new file mode 100644 index 000000000..9929666f6 --- /dev/null +++ b/scripts/inject-chunk-load-recovery.ts @@ -0,0 +1,91 @@ +/** + * Post-build step: inject the chunk-load recovery script as a real, + * parser-blocking inline `; + +async function collectHtmlFiles(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return collectHtmlFiles(full); + return entry.isFile() && entry.name.endsWith(".html") ? [full] : []; + }), + ); + return files.flat(); +} + +async function main() { + try { + await fs.access(OUT_DIR); + } catch { + throw new Error( + `Output directory not found: ${OUT_DIR}. Run "next build" first.`, + ); + } + + const htmlFiles = await collectHtmlFiles(OUT_DIR); + if (htmlFiles.length === 0) { + throw new Error(`No HTML files found under ${OUT_DIR}.`); + } + + let injected = 0; + let skipped = 0; + const missingHead: string[] = []; + + for (const file of htmlFiles) { + const html = await fs.readFile(file, "utf8"); + + if (html.includes(`id="${MARKER_ID}"`)) { + skipped += 1; + continue; + } + + const headIndex = html.indexOf(""); + if (headIndex === -1) { + missingHead.push(path.relative(OUT_DIR, file)); + continue; + } + + const insertAt = headIndex + "".length; + const next = html.slice(0, insertAt) + SCRIPT_TAG + html.slice(insertAt); + await fs.writeFile(file, next); + injected += 1; + } + + console.log( + `[inject-chunk-load-recovery] injected into ${injected} file(s), ` + + `skipped ${skipped} already-injected file(s).`, + ); + + if (missingHead.length > 0) { + throw new Error( + `No found in ${missingHead.length} HTML file(s): ` + + missingHead.slice(0, 10).join(", ") + + (missingHead.length > 10 ? ", …" : ""), + ); + } +} + +main().catch((error) => { + console.error("[inject-chunk-load-recovery]", error); + process.exit(1); +}); diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 000000000..7301b14fe --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect } from "react"; + +type GlobalErrorProps = { + error: Error & { digest?: string }; + reset: () => void; +}; + +function isChunkLoadError(error: Error): boolean { + const message = `${error.name || ""} ${error.message || ""}`; + + return /ChunkLoadError|Failed to load chunk|Loading chunk .* failed|\/_next\/static\/chunks\//i.test( + message, + ); +} + +function scheduleReloadForChunkError(error: Error) { + if (!isChunkLoadError(error)) return; + + const storageKey = "ar-io-docs:chunk-load-recovery"; + const fallbackPrefix = "ar-io-docs:chunk-load-recovery="; + const retryWindowMs = 30000; + const maxReloads = 2; + const now = Date.now(); + const freshState = { count: 0, firstSeen: now }; + + function readFallbackState() { + try { + if ( + typeof window.name !== "string" || + !window.name.startsWith(fallbackPrefix) + ) { + return freshState; + } + + const parsed = JSON.parse(window.name.slice(fallbackPrefix.length)) as + | { count?: number; firstSeen?: number } + | null; + + return parsed && parsed.firstSeen && now - parsed.firstSeen <= retryWindowMs + ? { + count: parsed.count ?? 0, + firstSeen: parsed.firstSeen, + } + : freshState; + } catch { + return freshState; + } + } + + function writeFallbackState(state: { count: number; firstSeen: number }) { + try { + window.name = `${fallbackPrefix}${JSON.stringify(state)}`; + return true; + } catch { + return false; + } + } + + try { + const parsed = JSON.parse(sessionStorage.getItem(storageKey) || "null") as + | { count?: number; firstSeen?: number } + | null; + + const state = + parsed && parsed.firstSeen && now - parsed.firstSeen <= retryWindowMs + ? { + count: parsed.count ?? 0, + firstSeen: parsed.firstSeen, + } + : readFallbackState(); + + if (state.count >= maxReloads) return; + + const nextState = { ...state, count: state.count + 1 }; + sessionStorage.setItem(storageKey, JSON.stringify(nextState)); + + window.setTimeout(() => window.location.reload(), 250); + } catch { + const state = readFallbackState(); + + if (state.count >= maxReloads) return; + if (!writeFallbackState({ ...state, count: state.count + 1 })) return; + + window.setTimeout(() => window.location.reload(), 250); + } +} + +export default function GlobalError({ error, reset }: GlobalErrorProps) { + const isChunkError = isChunkLoadError(error); + + useEffect(() => { + console.error(error); + scheduleReloadForChunkError(error); + }, [error]); + + return ( + + +
+
+

+ ar.io Documentation +

+

+ The docs hit a loading error. +

+

+ {isChunkError + ? "This can happen when a gateway returns a transient error while loading an app chunk. Reloading usually routes the request through a healthy response." + : "Try reloading the docs, or use the button below to retry rendering this page."} +

+
+ + +
+
+
+ + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8660fc89e..8a1442d5f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -44,6 +44,15 @@ export default function Layout({ children }: { children: ReactNode }) { return ( + {/* + The chunk-load recovery script is NOT rendered here. Under + `output: "export"` the RSC renderer serializes any inline