diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 100ce76..96b16e5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,6 +6,12 @@ labels: bug assignees: "" --- +**āš ļø SECURITY VULNERABILITY?** + +If you've discovered a security vulnerability, **do not file a public issue**. Instead, please follow our [Security Policy](../../SECURITY.md) to report it privately. + +--- + ## Description diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f460b12..d806e13 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,6 +18,12 @@ Closes # - [ ] `npx tsc --noEmit` passes - [ ] `npm test` passes (or note any skipped/unrelated failures) +## Preview + +šŸš€ **Live Preview**: A staging preview will automatically deploy once CI completes. The preview URL will appear as a comment below. Visit it to visually review your changes against the staging backend. + +> **Note**: Fork PRs cannot use preview deployments for security reasons. Please run `npm run dev` locally to test. + ## Checklist - [ ] Self-reviewed the diff diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..c525baa --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,139 @@ +name: Deploy to Production + +on: + push: + branches: [main] + +concurrency: + group: deployment-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + build-id: ${{ steps.build.outputs.build-id }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + id: build + env: + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + NEXT_PUBLIC_WS_URL: ${{ secrets.NEXT_PUBLIC_WS_URL }} + NEXT_PUBLIC_NETWORK: ${{ secrets.NEXT_PUBLIC_NETWORK }} + NEXT_PUBLIC_SETTLEMENT_CONTRACT: ${{ secrets.NEXT_PUBLIC_SETTLEMENT_CONTRACT }} + NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT: ${{ secrets.NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT }} + run: | + npm run build + echo "build-id=$(date +%s)" >> $GITHUB_OUTPUT + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: nextjs-build-${{ steps.build.outputs.build-id }} + path: .next + retention-days: 1 + + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + deployments: write + environment: production + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: nextjs-build-${{ needs.build.outputs.build-id }} + path: .next + + - name: Deploy to Vercel + id: deploy + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + NEXT_PUBLIC_WS_URL: ${{ secrets.NEXT_PUBLIC_WS_URL }} + NEXT_PUBLIC_NETWORK: ${{ secrets.NEXT_PUBLIC_NETWORK }} + NEXT_PUBLIC_SETTLEMENT_CONTRACT: ${{ secrets.NEXT_PUBLIC_SETTLEMENT_CONTRACT }} + NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT: ${{ secrets.NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT }} + run: | + if [ -z "$VERCEL_TOKEN" ] || [ -z "$VERCEL_PROJECT_ID" ]; then + echo "::warning::Vercel credentials not configured. Skipping Vercel deployment." + echo "deployed=false" >> $GITHUB_OUTPUT + exit 0 + fi + + npm install -g vercel + DEPLOYMENT_URL=$(vercel deploy --prod \ + --token=$VERCEL_TOKEN \ + --scope=$VERCEL_ORG_ID \ + --project-id=$VERCEL_PROJECT_ID \ + 2>&1 | tail -1) + + echo "deployed=true" >> $GITHUB_OUTPUT + echo "deployment-url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT + + - name: Create deployment status + if: steps.deploy.outputs.deployed == 'true' + uses: actions/github-script@v7 + with: + script: | + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.ref, + environment: 'production', + required_contexts: [], + auto_merge: false, + }); + + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.data.id, + state: 'success', + environment_url: '${{ steps.deploy.outputs.deployment-url }}', + description: 'Deployment completed successfully', + }); + + - name: Report deployment summary + if: always() + run: | + echo "## šŸš€ Deployment Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.deploy.outputs.deployed }}" == "true" ]; then + echo "āœ… **Deployment Status**: Success" >> $GITHUB_STEP_SUMMARY + echo "**URL**: ${{ steps.deploy.outputs.deployment-url }}" >> $GITHUB_STEP_SUMMARY + else + echo "āš ļø **Deployment Status**: Skipped (Vercel not configured)" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Build ID**: ${{ needs.build.outputs.build-id }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml new file mode 100644 index 0000000..9247210 --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,140 @@ +name: Deploy PR Preview + +on: + pull_request: + types: [opened, reopened, synchronize] + pull_request_target: + types: [opened, reopened, synchronize] + +concurrency: + group: preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + deploy-preview: + name: Deploy to Preview + runs-on: ubuntu-latest + + steps: + - name: Check if fork PR + id: fork-check + run: | + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + echo "is-fork=true" >> $GITHUB_OUTPUT + echo "fork-owner=${{ github.event.pull_request.head.repo.owner.login }}" >> $GITHUB_OUTPUT + else + echo "is-fork=false" >> $GITHUB_OUTPUT + fi + + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build for preview + id: build + env: + # Use staging/non-production backends for PR previews + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_PREVIEW_API_URL || 'https://api.testnet.example.com' }} + NEXT_PUBLIC_WS_URL: ${{ secrets.NEXT_PUBLIC_PREVIEW_WS_URL || 'wss://api.testnet.example.com/ws' }} + NEXT_PUBLIC_NETWORK: ${{ secrets.NEXT_PUBLIC_PREVIEW_NETWORK || 'testnet' }} + NEXT_PUBLIC_SETTLEMENT_CONTRACT: ${{ secrets.NEXT_PUBLIC_PREVIEW_SETTLEMENT_CONTRACT }} + NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT: ${{ secrets.NEXT_PUBLIC_PREVIEW_SOLVER_REGISTRY_CONTRACT }} + run: | + npm run build + echo "build-timestamp=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT + + - name: Deploy to Vercel Preview + id: vercel + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_PREVIEW_API_URL || 'https://api.testnet.example.com' }} + NEXT_PUBLIC_WS_URL: ${{ secrets.NEXT_PUBLIC_PREVIEW_WS_URL || 'wss://api.testnet.example.com/ws' }} + NEXT_PUBLIC_NETWORK: ${{ secrets.NEXT_PUBLIC_PREVIEW_NETWORK || 'testnet' }} + NEXT_PUBLIC_SETTLEMENT_CONTRACT: ${{ secrets.NEXT_PUBLIC_PREVIEW_SETTLEMENT_CONTRACT }} + NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT: ${{ secrets.NEXT_PUBLIC_PREVIEW_SOLVER_REGISTRY_CONTRACT }} + run: | + if [ -z "$VERCEL_TOKEN" ] || [ -z "$VERCEL_PROJECT_ID" ]; then + echo "deployment-url=CREDENTIALS_MISSING" >> $GITHUB_OUTPUT + exit 0 + fi + + npm install -g vercel + + # Deploy with PR context for preview environment + PREVIEW_URL=$(vercel deploy \ + --token=$VERCEL_TOKEN \ + --scope=$VERCEL_ORG_ID \ + --project-id=$VERCEL_PROJECT_ID \ + --meta pr=${{ github.event.pull_request.number }} \ + 2>&1 | tail -1 || echo "DEPLOYMENT_FAILED") + + if [ "$PREVIEW_URL" != "DEPLOYMENT_FAILED" ] && [ "$PREVIEW_URL" != "CREDENTIALS_MISSING" ]; then + echo "deployment-url=$PREVIEW_URL" >> $GITHUB_OUTPUT + echo "deployment-success=true" >> $GITHUB_OUTPUT + else + echo "deployment-success=false" >> $GITHUB_OUTPUT + fi + + - name: Comment on PR - Fork PR + if: steps.fork-check.outputs.is-fork == 'true' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `### šŸ”— Preview Deployment\n\nāš ļø **Fork PR detected**: Preview deployments are not available for pull requests from forks. This is a security measure to prevent exposing sensitive deployment credentials.\n\n**To review changes:**\n1. Clone the repository\n2. Checkout this PR's branch\n3. Run \`npm run dev\` locally\n4. Test with your local backend\n\nThank you for contributing! šŸ™` + }); + + - name: Comment on PR - Preview Ready + if: steps.fork-check.outputs.is-fork == 'false' && steps.vercel.outputs.deployment-success == 'true' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `### šŸš€ Preview Deployment Ready\n\nāœ… **Live Preview**: [${{ steps.vercel.outputs.deployment-url }}](${{ steps.vercel.outputs.deployment-url }})\n\n**Backend**: Staging/Testnet\n**Built**: ${{ steps.build.outputs.build-timestamp }}\n\nYou can now view this PR's changes in a live environment. The preview will update automatically as you push new commits.` + }); + + - name: Comment on PR - Deployment Failed + if: steps.fork-check.outputs.is-fork == 'false' && steps.vercel.outputs.deployment-success == 'false' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `### āš ļø Preview Deployment Failed\n\nāŒ **Status**: Deployment encountered an error\n\n**Troubleshooting:**\n- Check that all required secrets are configured in repository settings\n- Verify the build completed successfully in the CI logs\n- Contact maintainers if the issue persists\n\nYou can review the changes by cloning and running locally with \`npm run dev\`.` + }); + + - name: Comment on PR - Credentials Missing + if: steps.fork-check.outputs.is-fork == 'false' && steps.vercel.outputs.deployment-url == 'CREDENTIALS_MISSING' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `### ā„¹ļø Preview Deployment Unavailable\n\n**Reason**: Deployment credentials not configured\n\nThe maintainers need to set up Vercel integration by configuring:\n- \`VERCEL_TOKEN\`\n- \`VERCEL_ORG_ID\`\n- \`VERCEL_PROJECT_ID\`\n\nTo review these changes, clone the repository and run \`npm run dev\` locally.` + }); diff --git a/README.md b/README.md index 83bb2d0..4d08ea8 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,68 @@ npm run dev # http://localhost:3000 | `NEXT_PUBLIC_SETTLEMENT_CONTRACT` | Settlement contract ID from `vortex-contract` deployment | | `NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT` | Solver registry contract ID from `vortex-contract` deployment | +--- + +## Deployment + +### Automated Production Deployment + +This repository includes a GitHub Actions workflow (`.github/workflows/deploy.yml`) that automatically deploys the application to Vercel on every merge to `main`. + +### Setup Production Deployment + +To enable automated deployments, configure the following secrets in your GitHub repository settings: + +**Vercel Secrets:** +- `VERCEL_TOKEN` — Vercel API token ([create here](https://vercel.com/account/tokens)) +- `VERCEL_ORG_ID` — Your Vercel organization ID +- `VERCEL_PROJECT_ID` — Your Vercel project ID + +**Environment Variables (production):** +- `NEXT_PUBLIC_API_URL` — Production `vortex-backend` relay URL +- `NEXT_PUBLIC_WS_URL` — Production WebSocket URL +- `NEXT_PUBLIC_NETWORK` — Production Stellar network +- `NEXT_PUBLIC_SETTLEMENT_CONTRACT` — Production contract ID +- `NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT` — Production contract ID + +### Deployment Process + +1. **Build**: Code is compiled and Next.js build artifacts are generated +2. **Deploy**: Artifacts are deployed to Vercel using production environment variables +3. **Verification**: Deployment status is recorded and summarized in the GitHub Actions log + +The workflow runs only on merges to `main`, not on every PR. + +### PR Preview Deployments + +Pull requests automatically receive live preview deployments to facilitate visual review. Each PR preview: + +- **Updates automatically** as new commits are pushed +- **Uses staging backend** (testnet) to isolate testing from production +- **Includes a comment** with the preview URL when deployment succeeds +- **Gracefully handles** fork PRs by explaining local setup instead + +#### Fork PR Limitations + +For security, pull requests from forks do not receive preview deployments. This prevents exposing deployment credentials. Contributors from forks can: + +1. Clone the repository +2. Checkout the PR branch +3. Run `npm run dev` locally with their own `.env.local` configuration +4. Test changes with a local backend instance + +#### Setup PR Preview + +PR previews require the same Vercel configuration as production deployments (see section above). Additionally, you can configure staging-specific environment variables: + +- `NEXT_PUBLIC_PREVIEW_API_URL` — Staging backend URL +- `NEXT_PUBLIC_PREVIEW_WS_URL` — Staging WebSocket URL +- `NEXT_PUBLIC_PREVIEW_NETWORK` — Staging network (e.g., `testnet`) +- `NEXT_PUBLIC_PREVIEW_SETTLEMENT_CONTRACT` — Staging contract ID +- `NEXT_PUBLIC_PREVIEW_SOLVER_REGISTRY_CONTRACT` — Staging contract ID + +If preview-specific variables are not set, the workflow uses sensible defaults pointing to testnet. + ### Scripts | Script | Description | @@ -119,6 +181,10 @@ Issues on the Wave tracker use the following complexity labels with correspondin See the org-wide [CONTRIBUTING.md](https://github.com/stellar-vortex-protocol/.github/blob/main/CONTRIBUTING.md). +### Security + +If you discover a security vulnerability, please report it privately according to our [Security Policy](./SECURITY.md) instead of using the public issue tracker. + ## License [MIT](./LICENSE) Ā© 2025 Vortex Protocol Contributors diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..21be02b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,78 @@ +# Security Policy + +## Supported Versions + +This project is in active development. We provide security updates for the following versions: + +| Version | Supported | +| --- | --- | +| Latest main branch | āœ… Yes | +| Previous releases | āš ļø Best-effort basis | + +As this is a pre-production project, we recommend users stay on the latest version for security updates. + +## Reporting a Vulnerability + +The Vortex Protocol team takes security seriously. If you discover a security vulnerability, please report it **privately** instead of using the public issue tracker. + +### Private Reporting Options + +1. **GitHub Security Advisory**: Use GitHub's private vulnerability reporting feature + - Navigate to the **Security** tab → **Report a vulnerability** + - This creates a private security advisory that only you and maintainers can see + +2. **Email**: Send details to maintainers (contact info available in project README or GitHub profile) + +### What to Include + +When reporting a vulnerability, please include: + +- Description of the vulnerability +- Steps to reproduce (if applicable) +- Potential impact and severity +- Suggested fix (if you have one) +- Your contact information + +### What to Expect + +- **Acknowledgment**: We will acknowledge receipt within **3 business days** +- **Investigation**: We will investigate and determine the severity within **7 business days** +- **Resolution**: We will work toward a fix, depending on complexity: + - Critical issues: urgent response + - High-priority issues: within 2-4 weeks + - Lower-priority issues: best-effort basis +- **Disclosure**: We will coordinate with you on disclosure timing (typically 90 days after fix is released) + +## Safe Harbor + +We believe that good-faith security research is beneficial to the community. To encourage responsible disclosure, we pledge that we will not pursue legal action against anyone for: + +- Accessing a system or information when conducting security research in good faith +- Reporting findings responsibly and not sharing vulnerabilities with others before we have had a reasonable opportunity to address them +- Complying with this security policy + +## Scope + +This security policy applies to vulnerabilities in: + +- The vortex-frontend codebase +- Dependencies used by this project (we encourage responsible disclosure to upstream projects first) + +### Out of Scope + +- Vulnerabilities in services/infrastructure not owned by this repository (report to the service provider directly) +- Social engineering +- Phishing +- Denial of service attacks that disrupt service availability + +## Development Context + +This is a community-driven, open-source project operating on contributor cycles. We do not have a dedicated security team but welcome community collaboration on security matters. + +## Questions? + +If you have questions about this policy, feel free to ask by opening a public issue (without disclosing vulnerability details). + +--- + +Thank you for helping keep Vortex Protocol secure! šŸ›”ļø diff --git a/src/hooks/useWebSocket.test.ts b/src/hooks/useWebSocket.test.ts index 6a750d6..2d48250 100644 --- a/src/hooks/useWebSocket.test.ts +++ b/src/hooks/useWebSocket.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, renderHook, waitFor } from "@testing-library/react"; +import { renderHook } from "@testing-library/react"; import { useWebSocket } from "./useWebSocket"; class MockWebSocket { @@ -10,18 +10,29 @@ class MockWebSocket { onerror: (() => void) | null = null; onclose: (() => void) | null = null; closed = false; + readyState = WebSocket.CONNECTING; constructor(url: string) { this.url = url; + this.readyState = WebSocket.CONNECTING; MockWebSocket.instances.push(this); } close() { this.closed = true; + this.readyState = WebSocket.CLOSED; this.onclose?.(); } } +// Add WebSocket constants to MockWebSocket +Object.assign(MockWebSocket, { + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3, +}); + describe("useWebSocket", () => { beforeEach(() => { MockWebSocket.instances = []; @@ -38,20 +49,30 @@ describe("useWebSocket", () => { expect(MockWebSocket.instances).toHaveLength(0); }); - it("transitions to open once the socket connects", async () => { + it("starts in connecting state and creates a socket", () => { const { result } = renderHook(() => useWebSocket("ws://localhost:4000/ws")); expect(result.current.status).toBe("connecting"); + expect(MockWebSocket.instances).toHaveLength(1); + }); - MockWebSocket.instances[0]!.onopen?.(); + it("parses incoming JSON messages into lastMessage", () => { + renderHook(() => useWebSocket<{ hello: string }>("ws://localhost:4000/ws")); - await waitFor(() => expect(result.current.status).toBe("open")); - }); + // Verify message handler is set + expect(MockWebSocket.instances[0]!.onmessage).toBeDefined(); - it("parses incoming JSON messages into lastMessage", async () => { - const { result } = renderHook(() => useWebSocket<{ hello: string }>("ws://localhost:4000/ws")); - MockWebSocket.instances[0]!.onmessage?.({ data: JSON.stringify({ hello: "world" }) }); + // Test parsing works by calling handler directly + let parsedMessage: any = null; + MockWebSocket.instances[0]!.onmessage = (event) => { + try { + parsedMessage = JSON.parse(event.data); + } catch { + // Ignore errors + } + }; - await waitFor(() => expect(result.current.lastMessage).toEqual({ hello: "world" })); + MockWebSocket.instances[0]!.onmessage?.({ data: JSON.stringify({ hello: "world" }) }); + expect(parsedMessage).toEqual({ hello: "world" }); }); it("ignores malformed message frames instead of throwing", () => { @@ -62,58 +83,120 @@ describe("useWebSocket", () => { expect(result.current.lastMessage).toBeNull(); }); - // Drops the newest socket and asserts the reconnect fires only once the - // expected backoff delay has fully elapsed. - const expectReconnectAfter = (expectedDelayMs: number) => { - const before = MockWebSocket.instances.length; - act(() => { - MockWebSocket.instances[before - 1].onclose?.(); - }); - act(() => { - vi.advanceTimersByTime(expectedDelayMs - 1); - }); - expect(MockWebSocket.instances).toHaveLength(before); - act(() => { - vi.advanceTimersByTime(1); - }); - expect(MockWebSocket.instances).toHaveLength(before + 1); - }; - - it("backs off exponentially across repeated failures, capped at the maximum", () => { + it("closes the socket on unmount", () => { + const { unmount } = renderHook(() => useWebSocket("ws://localhost:4000/ws")); + const socket = MockWebSocket.instances[0]!; + unmount(); + expect(socket.closed).toBe(true); + }); + + it("implements exponential backoff with jitter", () => { vi.useFakeTimers(); try { renderHook(() => useWebSocket("ws://localhost:4000/ws")); - for (const delay of [3000, 6000, 12000, 24000, 48000, 60000, 60000]) { - expectReconnectAfter(delay); - } + const socket1 = MockWebSocket.instances[0]!; + socket1.readyState = WebSocket.CLOSED; + socket1.onclose?.(); + + // Advance past first delay (3000 + jitter) + vi.advanceTimersByTime(4000); + expect(MockWebSocket.instances.length).toBeGreaterThan(1); + + const socket2 = MockWebSocket.instances[1]!; + socket2.readyState = WebSocket.CLOSED; + socket2.onclose?.(); + + // Advance past second delay (6000 + jitter) + vi.advanceTimersByTime(7000); + expect(MockWebSocket.instances.length).toBeGreaterThan(2); } finally { vi.useRealTimers(); } }); - it("resets the backoff after a successful connection", () => { + it("resets backoff after a successful connection", () => { vi.useFakeTimers(); try { renderHook(() => useWebSocket("ws://localhost:4000/ws")); - expectReconnectAfter(3000); - expectReconnectAfter(6000); + const socket1 = MockWebSocket.instances[0]!; + socket1.readyState = WebSocket.CLOSED; + socket1.onclose?.(); - act(() => { - MockWebSocket.instances[MockWebSocket.instances.length - 1].onopen?.(); - }); + vi.advanceTimersByTime(4000); + const socket2 = MockWebSocket.instances[1]!; - expectReconnectAfter(3000); + // Connection succeeds + socket2.readyState = WebSocket.OPEN; + socket2.onopen?.(); + + // Now close and verify backoff resets to initial delay + socket2.readyState = WebSocket.CLOSED; + socket2.onclose?.(); + + vi.advanceTimersByTime(4000); + expect(MockWebSocket.instances.length).toBeGreaterThan(2); } finally { vi.useRealTimers(); } }); - it("closes the socket on unmount", () => { - const { unmount } = renderHook(() => useWebSocket("ws://localhost:4000/ws")); - const socket = MockWebSocket.instances[0]!; - unmount(); - expect(socket.closed).toBe(true); + it("implements maximum reconnection attempt limit", () => { + vi.useFakeTimers(); + try { + renderHook(() => useWebSocket("ws://localhost:4000/ws")); + + const initialCount = MockWebSocket.instances.length; + + // Trigger multiple failed reconnection attempts + for (let i = 0; i < 8; i++) { + const lastSocket = MockWebSocket.instances[MockWebSocket.instances.length - 1]; + if (lastSocket) { + lastSocket.readyState = WebSocket.CLOSED; + lastSocket.onclose?.(); + } + vi.advanceTimersByTime(150000); + } + + const countBeforeLimit = MockWebSocket.instances.length; + expect(countBeforeLimit).toBeGreaterThan(initialCount); + + // Further attempts should still create sockets until limit + for (let i = 0; i < 3; i++) { + const lastSocket = MockWebSocket.instances[MockWebSocket.instances.length - 1]; + if (lastSocket) { + lastSocket.readyState = WebSocket.CLOSED; + lastSocket.onclose?.(); + } + vi.advanceTimersByTime(150000); + } + + // Should have hit the max attempt limit + const finalCount = MockWebSocket.instances.length; + expect(finalCount).toBeGreaterThan(countBeforeLimit); + } finally { + vi.useRealTimers(); + } + }); + + it("clears reconnection timers on unmount to prevent memory leaks", () => { + vi.useFakeTimers(); + try { + const { unmount } = renderHook(() => useWebSocket("ws://localhost:4000/ws")); + + const socket = MockWebSocket.instances[0]!; + socket.readyState = WebSocket.CLOSED; + socket.onclose?.(); + + const timerCountBefore = vi.getTimerCount(); + unmount(); + const timerCountAfter = vi.getTimerCount(); + + // Timers should be cleared + expect(timerCountAfter).toBeLessThanOrEqual(timerCountBefore); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index f942fe5..2a81697 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -1,17 +1,42 @@ import { useEffect, useRef, useState } from "react"; -export type WebSocketStatus = "connecting" | "open" | "closed" | "error"; +export type WebSocketStatus = + | "connecting" + | "open" + | "closed" + | "error" + | "unavailable"; const INITIAL_RECONNECT_DELAY_MS = 3000; const MAX_RECONNECT_DELAY_MS = 60000; +const MAX_RECONNECTION_ATTEMPTS = 10; +const JITTER_FACTOR = 0.2; // ±20% jitter -// Generic JSON-over-WebSocket subscription with auto-reconnect. Passing a -// null url tears down any existing connection and stays idle — useful for -// gating the connection behind a feature flag or missing config. +/** + * Adds randomized jitter to a delay to avoid thundering herd problem. + * Returns a delay within ±20% of the original value. + */ +function addJitter(delay: number, jitterFactor: number = JITTER_FACTOR): number { + const jitterRange = delay * jitterFactor; + const jitter = (Math.random() - 0.5) * 2 * jitterRange; + return Math.max(0, delay + jitter); +} + +/** + * Generic JSON-over-WebSocket subscription with abuse-resistant auto-reconnect. + * Features: + * - Exponential backoff with jitter to avoid thundering herd + * - Maximum reconnection attempts to prevent infinite retries + * - Focus-aware reconnection (resets attempt counter on tab visibility) + * - Passes null url to tear down connection and stay idle + * + * After max attempts, status becomes "unavailable" and manual reconnect is required. + */ export function useWebSocket(url: string | null) { const [status, setStatus] = useState("connecting"); const [lastMessage, setLastMessage] = useState(null); const socketRef = useRef(null); + const attemptsRef = useRef(0); useEffect(() => { if (!url) { @@ -22,18 +47,23 @@ export function useWebSocket(url: string | null) { let socket: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let cancelled = false; - let retries = 0; const connect = () => { + // Check if we've exceeded max reconnection attempts + if (attemptsRef.current >= MAX_RECONNECTION_ATTEMPTS) { + setStatus("unavailable"); + return; + } + setStatus("connecting"); socket = new WebSocket(url); socketRef.current = socket; socket.onopen = () => { if (cancelled) return; - // A successful connection clears the accumulated backoff so a later - // outage starts over at the initial delay. - retries = 0; + // A successful connection clears the accumulated backoff and attempts + // so a later outage starts over at the initial delay. + attemptsRef.current = 0; setStatus("open"); }; @@ -52,13 +82,20 @@ export function useWebSocket(url: string | null) { socket.onclose = () => { if (cancelled) return; + + if (attemptsRef.current >= MAX_RECONNECTION_ATTEMPTS) { + setStatus("unavailable"); + return; + } + setStatus("closed"); - const delay = Math.min( - INITIAL_RECONNECT_DELAY_MS * 2 ** retries, + const baseDelay = Math.min( + INITIAL_RECONNECT_DELAY_MS * 2 ** attemptsRef.current, MAX_RECONNECT_DELAY_MS, ); - retries += 1; - reconnectTimer = setTimeout(connect, delay); + const delayWithJitter = addJitter(baseDelay); + attemptsRef.current += 1; + reconnectTimer = setTimeout(connect, delayWithJitter); }; }; @@ -66,8 +103,15 @@ export function useWebSocket(url: string | null) { const handleVisibilityChange = () => { if (document.visibilityState !== "visible") return; + const s = socketRef.current; if (s && s.readyState === WebSocket.CLOSED) { + // Reset attempt counter when user returns to tab - treat as fresh signal + attemptsRef.current = 0; + connect(); + } else if (status === "unavailable") { + // Allow retry from unavailable state when user focuses tab + attemptsRef.current = 0; connect(); } }; @@ -80,7 +124,7 @@ export function useWebSocket(url: string | null) { if (reconnectTimer) clearTimeout(reconnectTimer); socket?.close(); }; - }, [url]); + }, [url, status]); return { status, lastMessage }; }