diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 01b3244..48c8b35 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -@pywire/maintainers \ No newline at end of file +* @pywire/maintainers diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..183967a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + # Workflow actions are pinned to commit SHAs; this keeps the pins (and their + # `# vX.Y.Z` comments) current. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + commit-message: + prefix: chore + include: scope diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c25638..51bd50a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,26 +5,31 @@ on: branches: - main +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest name: Build Check steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' - name: Install pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 10 - name: Install dependencies working-directory: site - run: pnpm install + run: pnpm install --frozen-lockfile --ignore-scripts - name: Build working-directory: site @@ -34,12 +39,14 @@ jobs: runs-on: ubuntu-latest name: Installer Tests steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' - - name: Test install.sh + - name: Test installer and router Worker run: node --test 'tests/*.test.mjs' diff --git a/.github/workflows/deploy-nightly.yml b/.github/workflows/deploy-nightly.yml new file mode 100644 index 0000000..cf76f60 --- /dev/null +++ b/.github/workflows/deploy-nightly.yml @@ -0,0 +1,64 @@ +name: Deploy Nightly + +# Puts a PR's landing-site build on nightly.pywire.dev. workflow_run always +# runs this file as it is on main, so a PR can change what gets built but not +# what this job does with the token: it only downloads the artifact that +# deploy.yml built (without secrets) and uploads it to the nightly branch. +on: + workflow_run: + workflows: [Deploy Landing Site] + types: [completed] + +permissions: {} + +jobs: + deploy-nightly: + if: >- + github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.head_repository.full_name == github.repository + && (github.event.workflow_run.event == 'pull_request' + || (github.event.workflow_run.event == 'workflow_dispatch' + && github.event.workflow_run.head_branch != 'main')) + # Strictly one deploy at a time: a running deploy is never cancelled, and + # a newer one waits for it (GitHub keeps only the newest waiting job). + concurrency: + group: deploy-landing-nightly + cancel-in-progress: false + runs-on: ubuntu-latest + # Restricted to main in the repo settings (workflow_run runs on main) and + # holds the Pages-only CLOUDFLARE_PAGES_TOKEN. + environment: + name: nightly + url: https://nightly.pywire.dev + permissions: + actions: read # the build artifact lives on the triggering run + name: Deploy Nightly + steps: + - name: Find build + id: build + # Drafts skip the build, so there is nothing to deploy. + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" \ + --jq '[.artifacts[] | select(.name == "landing-dist" and (.expired | not))] | length') + echo "found=$([ "$count" -gt 0 ] && echo true || echo false)" >>"$GITHUB_OUTPUT" + + - name: Download build + if: steps.build.outputs.found == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: landing-dist + path: dist + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Deploy to Cloudflare Pages + if: steps.build.outputs.found == 'true' + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 + with: + wranglerVersion: '4.145.0' + apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy dist --project-name=pywire-landing --branch=nightly diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 29f9e9d..a7555e1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,6 +3,12 @@ name: Deploy Landing Site # main deploys production. Nightly (nightly.pywire.dev) only takes PRs that are # ready for review: drafts and bare branch pushes never deploy. Mark a PR # ready, or push to one that already is, to put it on nightly. +# +# Building runs PR code and third-party install scripts, so it holds no +# secrets: it uploads site/dist as an artifact. Deploying only downloads that +# artifact and uploads it to Pages, in a job whose environment only main can +# use. PR builds deploy from deploy-nightly.yml (workflow_run), which runs +# main's copy of that workflow, never the PR's. on: workflow_dispatch: {} push: @@ -14,55 +20,79 @@ on: paths: - 'site/**' +permissions: {} + jobs: - deploy-landing: - # PRs: ready for review only, and only from this repo (forks get no secrets). + build: + # PRs: ready for review only, and only from this repo. if: >- github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) - # Strictly one deploy per target at a time: a running deploy is never - # cancelled, and a newer one waits for it (GitHub keeps only the newest - # waiting job, so an older queued deploy is dropped in its favour). Job - # level, so skipped draft runs never join the queue. - concurrency: - group: deploy-landing-${{ github.ref == 'refs/heads/main' && 'main' || 'nightly' }} - cancel-in-progress: false runs-on: ubuntu-latest permissions: contents: read - deployments: write - name: Deploy Landing Site - env: - TARGET_BRANCH: ${{ github.ref == 'refs/heads/main' && 'main' || 'nightly' }} + name: Build Landing Site steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Deploy the PR's own head, not GitHub's merge preview. ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' - name: Install pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 10 - name: Install dependencies working-directory: site - run: pnpm install + run: pnpm install --frozen-lockfile --ignore-scripts - name: Build working-directory: site run: pnpm run build + - name: Upload build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: landing-dist + path: site/dist + if-no-files-found: error + retention-days: 3 + + deploy-production: + needs: build + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + # Strictly one deploy at a time: a running deploy is never cancelled, and + # a newer one waits for it (GitHub keeps only the newest waiting job). + concurrency: + group: deploy-landing-main + cancel-in-progress: false + runs-on: ubuntu-latest + # The environment is restricted to main in the repo settings and holds + # CLOUDFLARE_PAGES_TOKEN, so no other branch's workflow can read it. + environment: + name: production + url: https://pywire.dev + permissions: {} + name: Deploy Landing Site + steps: + - name: Download build + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: landing-dist + path: dist + - name: Deploy to Cloudflare Pages - uses: cloudflare/wrangler-action@v4 + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + wranglerVersion: '4.145.0' + apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - gitHubToken: ${{ secrets.GITHUB_TOKEN }} - command: pages deploy site/dist --project-name=pywire-landing --branch=${{ env.TARGET_BRANCH }} + command: pages deploy dist --project-name=pywire-landing --branch=main diff --git a/.github/workflows/infra-apply.yml b/.github/workflows/infra-apply.yml index 14b2f24..f151869 100644 --- a/.github/workflows/infra-apply.yml +++ b/.github/workflows/infra-apply.yml @@ -3,8 +3,7 @@ name: Infra apply on: workflow_dispatch: {} -permissions: - contents: read +permissions: {} concurrency: group: terraform @@ -12,8 +11,16 @@ concurrency: jobs: apply: + # The real guard is the production environment: it is restricted to main + # in the repo settings and holds the write credentials, so a dispatch from + # any other branch (which runs that branch's copy of this file) gets + # nothing. The job-level `if` just skips such runs cleanly. + if: github.ref == 'refs/heads/main' + environment: production runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + contents: read defaults: run: working-directory: infra @@ -25,15 +32,11 @@ jobs: TF_VAR_maintainer_emails: ${{ secrets.MAINTAINER_EMAILS }} TF_IN_AUTOMATION: "true" steps: - - uses: actions/checkout@v7 - - - name: Main branch only - if: github.ref != 'refs/heads/main' - run: | - echo "::error::Infra apply only runs on main" - exit 1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: hashicorp/setup-terraform@v4.0.1 + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 with: terraform_version: 1.16.4 terraform_wrapper: false diff --git a/.github/workflows/infra-plan.yml b/.github/workflows/infra-plan.yml index c76fc29..fc710fc 100644 --- a/.github/workflows/infra-plan.yml +++ b/.github/workflows/infra-plan.yml @@ -1,5 +1,10 @@ name: Infra plan +# Plans only ever read. Every run (PRs, main, the weekly drift check) uses a +# read-only Cloudflare token and read-only state-bucket keys, and skips the +# state lock (-lock=false) because taking it is a write. PR runs execute the +# PR's own Terraform, so they must never see a credential that can change +# anything; applying is infra-apply.yml's job, in the production environment. on: pull_request: paths: ["infra/**", "worker/**"] @@ -9,10 +14,7 @@ on: schedule: - cron: "0 8 * * 1" # weekly drift check -permissions: - contents: read - pull-requests: write # plan comments - issues: write # drift issues +permissions: {} concurrency: group: terraform @@ -22,20 +24,26 @@ jobs: plan: runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + pull-requests: write # plan comments + issues: write # drift issues defaults: run: working-directory: infra env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + AWS_ACCESS_KEY_ID: ${{ secrets.R2_READONLY_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_READONLY_SECRET_ACCESS_KEY }} + TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_READONLY_TOKEN }} TF_VAR_forwarding_rules: ${{ secrets.EMAIL_FORWARDING_RULES }} TF_VAR_maintainer_emails: ${{ secrets.MAINTAINER_EMAILS }} TF_IN_AUTOMATION: "true" steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: hashicorp/setup-terraform@v4.0.1 + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 with: terraform_version: 1.16.4 terraform_wrapper: false @@ -50,7 +58,7 @@ jobs: echo "exitcode=$code" >>"$GITHUB_OUTPUT" exit 0 fi - terraform plan -input=false -no-color -detailed-exitcode -lock-timeout=120s >plan.txt 2>&1 + terraform plan -input=false -no-color -detailed-exitcode -lock=false >plan.txt 2>&1 code=$? # The plan is posted publicly (PR comments, drift issues) — scrub emails. sed -i -E 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/[email]/g' plan.txt @@ -60,7 +68,7 @@ jobs: - name: Comment plan on PR # A non-empty plan on a PR is expected — comment it, never fail the PR. if: github.event_name == 'pull_request' - uses: actions/github-script@v9.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const code = Number('${{ steps.plan.outputs.exitcode }}'); @@ -109,7 +117,7 @@ jobs: - name: Open drift issue (scheduled check) if: github.event_name == 'schedule' && steps.plan.outputs.exitcode != '0' - uses: actions/github-script@v9.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const title = 'Terraform drift on main'; diff --git a/infra/README.md b/infra/README.md index 6d7c37b..8513fc3 100644 --- a/infra/README.md +++ b/infra/README.md @@ -11,8 +11,11 @@ brew install hashicorp/tap/terraform ## Daily flow - PRs touching `infra/` or `worker/` get a **plan comment** (never fails the PR). -- Pushing to `main` with drift fails the **Infra plan** job → run **Infra apply** - (Actions → Infra apply → Run workflow) to converge. + Plans use read-only credentials and `-lock=false` (see + [Credentials and environments](#credentials-and-environments)). +- Pushing to `main` reports unapplied changes as a notice (only a plan error + fails) → run **Infra apply** (Actions → Infra apply → Run workflow) to + converge. - A weekly check opens a "Terraform drift on main" issue if live state diverges. ## Local runs @@ -32,32 +35,85 @@ terraform init terraform plan # reads TF_VARs from terraform.tfvars ``` -## Secrets (repo settings) +## Credentials and environments + +Every Cloudflare credential is scoped to one job. Anything a pull request can +run (its build, its Terraform, its copy of a workflow) only ever sees +read-only credentials; write credentials live in environments that only +`main` can use. + +| Workflow | Job | Environment | Cloudflare credential | +|---|---|---|---| +| `ci.yml` | build, tests | — | none | +| `deploy.yml` | `build` (runs `pnpm install`/PR code) | — | none; uploads `site/dist` as an artifact | +| `deploy.yml` | `deploy-production` (push to `main`) | `production` | `CLOUDFLARE_PAGES_TOKEN` | +| `deploy-nightly.yml` | `deploy-nightly` (`workflow_run` after a PR build) | `nightly` | `CLOUDFLARE_PAGES_TOKEN` | +| `infra-plan.yml` | `plan` (PRs, `main`, weekly) | — | `CLOUDFLARE_READONLY_TOKEN`, read-only R2 keys | +| `infra-apply.yml` | `apply` (manual, `main`) | `production` | `CLOUDFLARE_API_TOKEN`, read/write R2 keys | + +Deploy jobs never install dependencies from the repo: they download the +artifact the secret-free build job produced and run a pinned `wrangler`. +`deploy-nightly.yml` is triggered by `workflow_run`, which always runs the +file as it is on `main`, so a PR can change what is built for nightly but not +what the deploy job does with the token. + +### Environments (Settings → Environments) + +| Environment | Deployment branches | Secrets | +|---|---|---| +| `production` | Selected branches: `main` only (optionally add required reviewers) | `CLOUDFLARE_PAGES_TOKEN`, `CLOUDFLARE_API_TOKEN`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | +| `nightly` | Selected branches: `main` only (`workflow_run` runs on `main`) | `CLOUDFLARE_PAGES_TOKEN` | + +The branch restriction is what keeps the write tokens away from other +branches: a workflow on any other branch (a PR, or a manual dispatch of a +modified `infra-apply.yml`) cannot enter the environment, so it never gets +its secrets. Don't also keep these names as repository secrets — a repository +secret is readable from every branch. + +### Repository secrets (Settings → Secrets and variables → Actions) | Secret | Purpose | |---|---| -| `CLOUDFLARE_API_TOKEN` | provider auth — see [Token permissions](#token-permissions) for the exact grant list | -| `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` | state backend (Object Read & Write, scoped to `pywire-tfstate` only) | +| `CLOUDFLARE_ACCOUNT_ID` | account for `wrangler pages deploy` (not sensitive; it is also in `infra.auto.tfvars`) | +| `CLOUDFLARE_READONLY_TOKEN` | provider auth for `terraform plan` — the read-only grant list below | +| `R2_READONLY_ACCESS_KEY_ID` / `R2_READONLY_SECRET_ACCESS_KEY` | state backend for plans (Object Read only, scoped to `pywire-tfstate`) | | `EMAIL_FORWARDING_RULES` / `MAINTAINER_EMAILS` | private tfvars values for CI | -## Token permissions +`R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` (Object Read & Write, scoped to +`pywire-tfstate` only) are `production` environment secrets. -The `CLOUDFLARE_API_TOKEN` must be an **account token** (verify: passes -`/accounts/{id}/tokens/verify`, fails `/user/tokens/verify`). Current picker -names (2026-09) — dashboard says *Write*, older docs say *Edit*: +## Token permissions -| Scope | Permission | -|---|---| -| Account | Pages: Write | -| Account | Workers R2 Storage: Write | -| Account | Workers Scripts: Write | -| Account | Email Routing Addresses: Write | -| Account | Account Rulesets: Write | -| Zone (pywire.dev) | Email Routing Rules: Write | -| Zone (pywire.dev) | Workers Routes: Write | -| Zone (pywire.dev) | DNS: Write | -| Zone (pywire.dev) | Zone WAF: Write | -| Zone (pywire.dev) | Zone Settings: **Read AND Write** | +All three are **account tokens** (verify: passes +`/accounts/{id}/tokens/verify`, fails `/user/tokens/verify`), restricted to +this account and, for zone permissions, to the `pywire.dev` zone. Current +picker names (2026-09) — dashboard says *Write*, older docs say *Edit*. + +**`CLOUDFLARE_PAGES_TOKEN`** (site deploys): Account → Cloudflare Pages: +Write, nothing else. Cloudflare cannot scope it to one project or branch, +so it can still publish any Pages project in the account; that is why it +only lives in environments restricted to `main`. + +**`CLOUDFLARE_API_TOKEN`** (`terraform apply`) and +**`CLOUDFLARE_READONLY_TOKEN`** (`terraform plan`) have the same grants, at +*Write* and *Read* respectively: + +| Scope | `CLOUDFLARE_API_TOKEN` | `CLOUDFLARE_READONLY_TOKEN` | +|---|---|---| +| Account | Pages: Write | Pages: Read | +| Account | Workers R2 Storage: Write | Workers R2 Storage: Read | +| Account | Workers Scripts: Write | Workers Scripts: Read | +| Account | Email Routing Addresses: Write | Email Routing Addresses: Read | +| Account | Account Rulesets: Write | Account Rulesets: Read | +| Zone (pywire.dev) | Email Routing Rules: Write | Email Routing Rules: Read | +| Zone (pywire.dev) | Workers Routes: Write | Workers Routes: Read | +| Zone (pywire.dev) | DNS: Write | DNS: Read | +| Zone (pywire.dev) | Zone WAF: Write | Zone WAF: Read | +| Zone (pywire.dev) | Zone Settings: **Read AND Write** | Zone Settings: Read | + +Before relying on a new read-only token, run `terraform plan -lock=false` +locally with it (and the read-only R2 keys): the plan must succeed and match +a plan made with the write token. Wrinkles found the hard way: @@ -82,11 +138,35 @@ Wrinkles found the hard way: - **Project recreation drops custom domains AND their DNS record** (Pages auto-deletes the zone CNAME when the project is destroyed, 2026-09 live lesson). Both `cloudflare_pages_domain` resources and the docs CNAME are - now terraform-managed; after any `terraform apply -replace` of a Pages - project, re-check the domain attach and CNAME, then dispatch the site - deploy. Also: Pages refuses to delete a project with too many deployments — - prune them via the API first (`accounts/.../pages/projects/
/deployments`). + now terraform-managed, and both Pages projects carry + `prevent_destroy = true`, so a plan that would destroy one fails instead. + To recreate one on purpose, drop `prevent_destroy` in the same PR, then + after `terraform apply -replace` re-check the domain attach and CNAME, + dispatch the site deploy, and restore `prevent_destroy`. Also: Pages + refuses to delete a project with too many deployments — prune them via the + API first (`accounts/.../pages/projects/
/deployments`).
- **Provider upgrades past 5.16 are blocked**: 5.24+ cannot read this state's
`email_routing_settings` (new `support_subaddress` field vs old state objects).
To upgrade: `terraform state rm` the four email-routing resources, re-import
them with the new provider, then bump the lock.
+- **The router gets the Pages hostnames from Terraform.** `worker/src/index.js`
+ reads `LANDING_HOST` / `DOCS_HOST` (plain-text bindings set from the Pages
+ projects' `subdomain`); nightly prefixes `nightly.`. It answers 500 rather
+ than guess if they are missing, and rewrites any redirect naming a
+ `*.pages.dev` host back onto the public origin.
+- **HTTPS and security headers.** `always_use_https` is on for the zone, and
+ the router redirects `http://` and `www.` itself, then adds HSTS
+ (`max-age=31536000; includeSubDomains`), `nosniff`, `Referrer-Policy`,
+ `Permissions-Policy`, framing rules and a CSP to everything it serves. The
+ docs CSP is **report-only** apart from `frame-ancestors`, `object-src` and
+ `base-uri`: the tutorial loads Pyodide from jsDelivr, runs WebAssembly and
+ blob: workers, and installs from PyPI. Walk through the tutorial with the
+ console open; once it reports nothing, move the report-only policy to the
+ enforced header in the Worker. `docs.pywire.dev` is served by Pages
+ directly and gets none of these headers.
+- **`www.pywire.dev` DNS is not in Terraform.** Its proxied record predates
+ this config; the `www` Worker route makes the router redirect it to the
+ apex. To manage the record here, look up its id
+ (`GET /zones/{zone_id}/dns_records?name=www.pywire.dev`), add a
+ `cloudflare_dns_record "www"` matching it plus an `import` block with id
+ `Links for ${pkgName}
\n${links}\n`,
+ { headers: { "Content-Type": "text/html; charset=utf-8", "Access-Control-Allow-Origin": "*" } },
+ );
+ }
- // Define base targets based on environment
- const landingTarget = isNightly ? "nightly.pywire-landing.pages.dev" : "pywire-landing.pages.dev";
- const docsTarget = isNightly ? "nightly.pywire-docs.pages.dev" : "pywire-docs.pages.dev";
+ const key = path.slice("/cdn/".length);
+ if (!key) return new Response("Not Found", { status: 404 });
+ const obj = await env.CDN_BUCKET.get(key);
+ if (!obj) return new Response("Not Found", { status: 404 });
+ const contentType = obj.httpMetadata?.contentType ?? "application/octet-stream";
+ return new Response(obj.body, {
+ headers: {
+ "Content-Type": contentType,
+ "Cache-Control": "public, max-age=31536000, immutable",
+ "Access-Control-Allow-Origin": "*",
+ },
+ });
+}
- // --- 1. CDN (R2 bucket proxy + PEP 503 simple index) ---
- if (path.startsWith('/cdn/simple/')) {
- const pkgName = path.slice('/cdn/simple/'.length).replace(/\/$/, '')
- if (!pkgName) return new Response('Not Found', { status: 404 })
- const listed = await env.CDN_BUCKET.list({ prefix: `${pkgName}/` })
- if (listed.objects.length === 0)
- return new Response('Not Found', { status: 404, headers: { 'Access-Control-Allow-Origin': '*' } })
- const links = listed.objects
- .map(obj => {
- const filename = obj.key.split('/').pop()
- return `${filename}`
- })
- .join('\n')
- return new Response(
- `Links for ${pkgName}
\n${links}\n`,
- { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Access-Control-Allow-Origin': '*' } }
- )
+const SHORTCUTS = {
+ "/github": "https://github.com/pywire/pywire",
+};
+
+export default {
+ async fetch(request, env) {
+ const url = new URL(request.url);
+
+ // --- 0. CANONICAL ORIGIN: HTTPS, no www ---
+ if (url.protocol === "http:" || url.hostname.startsWith("www.")) {
+ url.protocol = "https:";
+ url.port = "";
+ if (url.hostname.startsWith("www.")) url.hostname = url.hostname.slice("www.".length);
+ return redirect(url.toString(), 301);
}
- if (path.startsWith('/cdn/')) {
- const key = path.slice('/cdn/'.length)
- if (!key) return new Response('Not Found', { status: 404 })
- const obj = await env.CDN_BUCKET.get(key)
- if (!obj) return new Response('Not Found', { status: 404 })
- const contentType = obj.httpMetadata?.contentType ?? 'application/octet-stream'
- return new Response(obj.body, {
- headers: {
- 'Content-Type': contentType,
- 'Cache-Control': 'public, max-age=31536000, immutable',
- 'Access-Control-Allow-Origin': '*',
- },
- })
+ if (!env.LANDING_HOST || !env.DOCS_HOST) {
+ return new Response("Router misconfigured: LANDING_HOST and DOCS_HOST must be bound", { status: 500 });
}
+ const isNightly = url.hostname.startsWith("nightly.");
+ const landingHost = isNightly ? `nightly.${env.LANDING_HOST}` : env.LANDING_HOST;
+ const docsHost = isNightly ? `nightly.${env.DOCS_HOST}` : env.DOCS_HOST;
+ const path = url.pathname;
- // --- 2. HANDLE SHORTCUT REDIRECTS ---
- const redirects = {
- // "/discord": "https://discord.gg/pywire", // Update this!
- "/github": "https://github.com/pywire/pywire",
- };
- if (redirects[path]) return Response.redirect(redirects[path], 302);
-
- // --- 3. DEFINE PROXY FUNCTION ---
- // This helper strips the 'Host' header so Pages accepts the request
- async function proxy(targetOrigin, pathOverride) {
- const newUrl = new URL(request.url);
- newUrl.hostname = targetOrigin;
-
- // Apply path override if provided (for stripping /docs)
- if (pathOverride !== undefined) {
- newUrl.pathname = pathOverride;
- }
-
- // ⚠️ CRITICAL: Create a clean request to avoid Host header mismatch
- const newRequest = new Request(newUrl, {
- method: request.method,
- headers: request.headers,
- body: request.body,
- redirect: "manual",
- });
-
- // Force the Host header to match the target origin
- newRequest.headers.set("Host", targetOrigin);
-
- return fetch(newRequest);
+ // --- 1. CDN (R2 bucket proxy + PEP 503 simple index) ---
+ if (path.startsWith("/cdn/")) {
+ return withHeaders(await serveCdn(path, env), SECURITY_HEADERS.cdn);
}
- // --- 4. ROUTE TO DOCS ---
+ // --- 2. SHORTCUT REDIRECTS ---
+ if (Object.hasOwn(SHORTCUTS, path)) return redirect(SHORTCUTS[path], 302);
+
+ // --- 3. DOCS (the origin sees "/_astro/..." or "/") ---
if (path === "/docs" || path.startsWith("/docs/")) {
- // Strip "/docs" so the origin sees "/_astro/..." or "/"
- const newPath = path.replace(/^\/docs/, "") || "/";
- return withDocsBase(await proxy(docsTarget, newPath), docsTarget);
+ const upstream = await proxy(request, docsHost, path.replace(/^\/docs/, "") || "/");
+ return withHeaders(rewriteLocation(upstream, docsHost, "/docs"), SECURITY_HEADERS.docs);
}
- // --- 5. ROUTE TO LANDING ---
- return proxy(landingTarget);
+ // --- 4. LANDING ---
+ const upstream = await proxy(request, landingHost, path);
+ return withHeaders(rewriteLocation(upstream, landingHost, ""), SECURITY_HEADERS.landing);
},
};