From bae2a3d74918fa16e856ffe8351377df6d29bf48 Mon Sep 17 00:00:00 2001 From: Connor Lewis <50084106+imconnorngl@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:17:08 +0100 Subject: [PATCH] fix: improve Worker previews and upstream API requests --- .github/workflows/deploy-workers.yml | 41 +++---------- src/lib/shared/api/mutator/custom-instance.ts | 39 +++++++----- src/lib/shared/api/mutator/readApiResponse.ts | 59 +++++++++++++++++++ 3 files changed, 91 insertions(+), 48 deletions(-) create mode 100644 src/lib/shared/api/mutator/readApiResponse.ts diff --git a/.github/workflows/deploy-workers.yml b/.github/workflows/deploy-workers.yml index 4968dfcfa..6716b4ef0 100644 --- a/.github/workflows/deploy-workers.yml +++ b/.github/workflows/deploy-workers.yml @@ -28,12 +28,6 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Require Wrangler environment - if: vars.CLOUDFLARE_ENV == '' - run: | - echo "::error::Set CLOUDFLARE_ENV in the selected GitHub environment." - exit 1 - - name: Install dependencies uses: ./.github/actions/pnpm-install @@ -48,29 +42,11 @@ jobs: PUBLIC_API_URL: https://sky.shiiyu.moe/api/ SERVER_API_TOKEN: ${{ secrets.SERVER_API_TOKEN }} - - name: Validate Worker bundle - run: pnpm exec wrangler deploy --dry-run --no-x-provision --env "$CLOUDFLARE_ENV" - - name: Get preview alias id: ref if: github.ref != 'refs/heads/dev' run: echo "ref_name=${GITHUB_REF_NAME//[^a-zA-Z0-9-]/-}" >> "$GITHUB_OUTPUT" - - name: Initialize Preview Worker - if: github.ref != 'refs/heads/dev' - run: | - if pnpm exec wrangler versions list --env "$CLOUDFLARE_ENV" --json > "$RUNNER_TEMP/worker-versions.json" 2> "$RUNNER_TEMP/worker-versions-error.log"; then - exit 0 - fi - if ! grep -Fq '[code: 10007]' "$RUNNER_TEMP/worker-versions-error.log"; then - cat "$RUNNER_TEMP/worker-versions-error.log" >&2 - exit 1 - fi - pnpm exec wrangler deploy --no-x-provision --env "$CLOUDFLARE_ENV" - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - - name: Deploy uses: cloudflare/wrangler-action@v3 with: @@ -78,13 +54,14 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} gitHubToken: ${{ github.token }} environment: ${{ vars.CLOUDFLARE_ENV }} - command: ${{ github.ref == 'refs/heads/dev' && 'deploy --no-x-provision' || format('versions upload --preview-alias {0} --tag {1}-{2}', steps.ref.outputs.ref_name, github.run_id, github.run_attempt) }} + command: deploy --no-x-provision - # Preview uploads do not apply tail consumers or observability settings. - - name: Deploy Preview Version + - name: Upload Preview if: github.ref != 'refs/heads/dev' - run: pnpm exec wrangler versions deploy --version-tag "$VERSION_TAG@100%" --env "$CLOUDFLARE_ENV" --yes --no-x-provision - env: - VERSION_TAG: ${{ github.run_id }}-${{ github.run_attempt }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + gitHubToken: ${{ github.token }} + environment: ${{ vars.CLOUDFLARE_ENV }} + command: versions upload --preview-alias ${{ steps.ref.outputs.ref_name }} --tag ${{ github.run_id }}-${{ github.run_attempt }} --no-x-provision diff --git a/src/lib/shared/api/mutator/custom-instance.ts b/src/lib/shared/api/mutator/custom-instance.ts index 8ad26b848..654c34bd8 100644 --- a/src/lib/shared/api/mutator/custom-instance.ts +++ b/src/lib/shared/api/mutator/custom-instance.ts @@ -1,20 +1,10 @@ import { getRequestEvent } from "$app/server"; import { env as envPrivate } from "$env/dynamic/private"; import { env as envPublic } from "$env/dynamic/public"; +import { readApiResponse } from "./readApiResponse"; const { PUBLIC_SERVER_API_URL } = envPublic; -// NOTE: Supports cases where `content-type` is other than `json` -const getBody = (c: Response | Request): Promise => { - const contentType = c.headers.get("content-type"); - - if (contentType && contentType.includes("application/json")) { - return c.json(); - } - - return c.text() as Promise; -}; - // NOTE: Update just base url const getUrl = (contextUrl: string): string => { // Remove the trailing /api/ if present @@ -43,17 +33,34 @@ export const customFetch = async (url: string, options: RequestInit): Promise (envPrivate.SERVER_API_TOKEN as App.Platform["env"]["SERVER_API_TOKEN"]) ?? event?.platform?.env.SERVER_API_TOKEN; const serverApiToken = typeof token === "string" ? token : ((await token?.get()) ?? ""); + const headers = new Headers(options.headers); + // Only resource-pack preferences belong to the API. Forwarding browser cookies and + // Access JWT headers can exceed the upstream request-header limit. + const enabledPacksCookie = event?.request.headers + .get("cookie") + ?.split(";") + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith("enabledPacks=")); + if (enabledPacksCookie && !headers.has("cookie")) { + headers.set("Cookie", enabledPacksCookie); + } + // Replace any caller token regardless of its header casing. + headers.delete("X-API-Token"); + const requestInit: RequestInit = { ...options, + // Prevent SvelteKit's fetch from adding browser cookies/authorization back. + credentials: "omit", headers: { - ...(event ? Object.fromEntries(event.request.headers) : {}), - ...options.headers, - "X-API-Token": serverApiToken + ...Object.fromEntries(headers), + "X-API-Token": serverApiToken, + "User-Agent": "Lunar Client (skycrypt-embed.lunarclient.com)" } }; - const response = await (event?.fetch ?? fetch)(getUrl(url), requestInit); - const data = await getBody(response); + const requestUrl = getUrl(url); + const response = await (event?.fetch ?? fetch)(requestUrl, requestInit); + const data = await readApiResponse(response, requestUrl, requestInit.method); return { status: response.status, data, headers: response.headers } as T; }; diff --git a/src/lib/shared/api/mutator/readApiResponse.ts b/src/lib/shared/api/mutator/readApiResponse.ts new file mode 100644 index 000000000..c695772ea --- /dev/null +++ b/src/lib/shared/api/mutator/readApiResponse.ts @@ -0,0 +1,59 @@ +import { error } from "@sveltejs/kit"; + +/** Reject upstream error pages before they can be used or prerendered as API data. */ +export async function readApiResponse(response: Response, requestUrl: string, method = "GET"): Promise { + const { origin, pathname } = new URL(requestUrl); + const contentType = response.headers.get("content-type"); + const mediaType = contentType?.split(";", 1)[0].trim().toLowerCase(); + const rayId = response.headers.get("cf-ray"); + // Omit query strings, credentials, and response bodies from diagnostics. + const details = [ + `upstream HTTP ${response.status} ${response.statusText}`.trim(), + `Content-Type: ${contentType || "missing"}`, + ...(rayId ? [`CF-Ray: ${rayId}`] : []) + ].join("; "); + + const fail = (reason: string, status = 502): never => { + error(status, `SkyCrypt API ${method} ${origin}${pathname} failed: ${reason} (${details})`); + }; + + if (response.headers.get("cf-mitigated") === "challenge") { + await response.body?.cancel(); + fail("Cloudflare challenged the request (cf-mitigated: challenge). Check the upstream WAF/bot protection rules."); + } + + const isJson = + mediaType === "application/json" || (mediaType?.startsWith("application/") && mediaType.endsWith("+json")); + if (!isJson) { + // The generated client also exposes image-rendering endpoints that return PNG bytes. + if (response.ok && mediaType === "image/png") { + return response.blob(); + } + + await response.body?.cancel(); + const hint = + mediaType === "text/html" + ? " HTML may indicate a WAF challenge, an Access login page, or an upstream proxy error." + : ""; + fail(`Expected a JSON API response.${hint}`); + } + + let data: unknown; + try { + data = await response.json(); + } catch { + fail("The upstream API returned invalid JSON."); + } + + const apiError = + typeof data === "object" && data !== null && "error" in data && typeof data.error === "string" + ? data.error + : undefined; + + if (!response.ok || apiError) { + const status = response.status >= 400 && response.status < 600 ? response.status : 502; + fail(apiError || "The upstream API returned an unsuccessful response.", status); + } + + return data; +}