diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 296c2a268dcf..3d80f675b81b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -16,3 +16,6 @@ docs/articles/ @stephanieelliott # Claude Code skills /.claude/skills/ @Julesssss @Expensify/pullerbear + +# Cloudflare Worker that serves the web build +/web/cloudflare-worker/ @justinpersaud @AndrewGable @roryabraham diff --git a/.github/workflows/cloudflareWorkerTests.yml b/.github/workflows/cloudflareWorkerTests.yml new file mode 100644 index 000000000000..aff7d9d745ed --- /dev/null +++ b/.github/workflows/cloudflareWorkerTests.yml @@ -0,0 +1,51 @@ +name: Cloudflare Worker tests + +on: + pull_request: + types: [opened, synchronize] + branches-ignore: [staging, production] + paths: + - 'web/cloudflare-worker/**' + # The tests check its inline scripts against the CSP hashes. + - 'web/index.html' + - '.nvmrc' + - '.github/workflows/cloudflareWorkerTests.yml' + +concurrency: + group: ${{ github.ref }}-cloudflare-worker-tests + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Cloudflare Worker tests + if: github.actor != 'OSBotify' + runs-on: blacksmith-2vcpu-ubuntu-2404 + defaults: + run: + working-directory: web/cloudflare-worker + env: + WRANGLER_SEND_METRICS: false + steps: + - name: Checkout + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 + + # The Worker is a standalone package, so this skips the root dependencies that setupNode restores. + - name: Setup Node + # v6.1.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: web/cloudflare-worker/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0621b4cda065..69400d417946 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -770,6 +770,54 @@ jobs: env: HOST: ${{ env.DEPLOY_ENV == 'production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }} + webDeployTestStaging: + name: Deploy Web to test-staging.new.expensify.com + needs: [prep, webBuild] + if: ${{ needs.webBuild.result == 'success' && needs.prep.outputs.DEPLOY_ENV == 'staging' }} + # Trial hosting for the web build, so a failure here must not fail the staging deploy. + continue-on-error: true + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 + with: + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} + + - name: Setup Node + # v6.1.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: web/cloudflare-worker/package-lock.json + + - name: Download web build artifact + # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: web-build-tar-gz-artifact + path: ./ + + - name: Extract web build + run: tar -xzf "${{ needs.webBuild.outputs.TAR_FILENAME }}" + + - name: Install Cloudflare Worker dependencies + working-directory: web/cloudflare-worker + run: npm ci + + - name: Deploy to Cloudflare Workers + working-directory: web/cloudflare-worker + run: npx wrangler deploy --env test-staging + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + WRANGLER_SEND_METRICS: false + + - name: Verify deploy + run: | + APP_VERSION=$(jq -r .version < package.json) + ./.github/scripts/verifyDeploy.sh https://test-staging.new.expensify.com "$APP_VERSION" + buildStorybook: name: Build storybook docs needs: [prep] diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index 10e73863e75b..334840173206 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -846,6 +846,8 @@ const config = defineConfig([ '**/vendor', 'modules/group-ib-fp/**/*', 'web/snippets/gib.js', + // Standalone npm package with its own dependencies and typecheck (see web/cloudflare-worker/README.md) + 'web/cloudflare-worker/**/*', // Generated language files - excluded from ESLint but still type-checked 'src/languages/de.ts', 'src/languages/el.ts', diff --git a/cspell.json b/cspell.json index 232fb5466088..3c374f0fa162 100644 --- a/cspell.json +++ b/cspell.json @@ -1187,7 +1187,15 @@ "knip", "lottiefiles", "jsitooling", - "Refetched" + "Refetched", + "AASA", + "assetsignore", + "bcmap", + "HSTS", + "Miniflare", + "nosniff", + "SAMEORIGIN", + "workerd" ], "ignorePaths": [ ".github/actions/javascript/**/index.js", diff --git a/knip.json b/knip.json index 72c09c1b8500..b659f616ef55 100644 --- a/knip.json +++ b/knip.json @@ -42,6 +42,7 @@ ".github/actions/**/index.js", "tests/perf-test/**", "web/snippets/gib.js", + "web/cloudflare-worker/**", "src/libs/actions/connections/FinancialForce.ts", "src/setup/telemetry/noopExpoUpdates.ts" ], diff --git a/web/cloudflare-worker/.assetsignore b/web/cloudflare-worker/.assetsignore new file mode 100644 index 000000000000..0bd29c77a5a9 --- /dev/null +++ b/web/cloudflare-worker/.assetsignore @@ -0,0 +1,4 @@ +# Precompressed twins. Cloudflare compresses Worker responses at the edge. +*.br +# Source maps are uploaded to Sentry at build time and must not be publicly served. +*.map diff --git a/web/cloudflare-worker/.gitignore b/web/cloudflare-worker/.gitignore new file mode 100644 index 000000000000..b2f02dedeefe --- /dev/null +++ b/web/cloudflare-worker/.gitignore @@ -0,0 +1,3 @@ +.wrangler/ +.dev.vars* +worker-configuration.d.ts diff --git a/web/cloudflare-worker/.npmrc b/web/cloudflare-worker/.npmrc new file mode 100644 index 000000000000..317d26831b83 --- /dev/null +++ b/web/cloudflare-worker/.npmrc @@ -0,0 +1,4 @@ +engine-strict=true + +# Supply chain protection: only resolve package versions published at least 7 days ago. +min-release-age=7 diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md new file mode 100644 index 000000000000..240e22d88fcd --- /dev/null +++ b/web/cloudflare-worker/README.md @@ -0,0 +1,130 @@ +# New Expensify Cloudflare Worker + +This package serves the New Expensify web build (`dist/`) from Cloudflare with [Workers Static Assets](https://developers.cloudflare.com/workers/static-assets/). + +The build and the Worker are uploaded together as one Worker version. A deploy is therefore atomic, and a rollback restores both the code and the assets. + +## What the Worker does + +Every request runs the Worker first (`run_worker_first: true`), which reads the file from the `ASSETS` binding and then: + +| Request | Response | +| --- | --- | +| Any path | Security headers: HSTS, CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Document-Policy` | +| HTML (`/`, `/index.html`, any SPA deep link) | Each `", + ' ', + ' ', + '
', + ' ', + ' ', + '