From a68c1bc9aed1bd8a1f633df9d1b6b4545cd96c53 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Thu, 16 Apr 2026 01:40:57 -0400 Subject: [PATCH 1/2] fix: proxy API through Cloudflare Pages Function to fix iOS Safari guest sessions iOS Safari's ITP blocks Set-Cookie headers from cross-origin fetch() requests, even with SameSite=None; Secure, causing guest session tokens to never be stored. Adds a catch-all Pages Function that proxies /api/* to the backend API_URL so all requests are same-origin and cookies are treated as first-party. --- packages/web/functions/api/[[path]].ts | 65 ++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/web/functions/api/[[path]].ts diff --git a/packages/web/functions/api/[[path]].ts b/packages/web/functions/api/[[path]].ts new file mode 100644 index 0000000..48cbc3e --- /dev/null +++ b/packages/web/functions/api/[[path]].ts @@ -0,0 +1,65 @@ +// Cloudflare Pages Function: proxy all /api/* requests to the backend API. +// This keeps everything on the same origin (tabby.pages.dev) so session cookies +// work on iOS Safari, which blocks cross-site cookies from fetch() requests. +// +// Required env var in Cloudflare Pages dashboard: API_URL (e.g. https://tabby-api.onrender.com) + +interface Env { + API_URL: string; +} + +// Headers that must not be forwarded to/from the upstream server +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', + 'upgrade', +]); + +export const onRequest: (context: { request: Request; env: Env; params: Record }) => Promise = async (context) => { + const { request, env } = context; + const apiBase = env.API_URL?.replace(/\/$/, ''); + + if (!apiBase) { + return new Response(JSON.stringify({ error: 'API_URL not configured' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const url = new URL(request.url); + const target = new URL(url.pathname + url.search, apiBase); + + // Forward request headers, stripping hop-by-hop + const reqHeaders = new Headers(); + request.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) { + reqHeaders.set(key, value); + } + }); + + const hasBody = !['GET', 'HEAD'].includes(request.method); + const upstream = await fetch(target.toString(), { + method: request.method, + headers: reqHeaders, + ...(hasBody && { body: request.body }), + redirect: 'manual', + }); + + // Forward response headers, stripping hop-by-hop + const resHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) { + resHeaders.append(key, value); + } + }); + + return new Response(upstream.body, { + status: upstream.status, + headers: resHeaders, + }); +}; From 16c06fa996aa9444525c65dd42f5035627f0873e Mon Sep 17 00:00:00 2001 From: David Zhao Date: Thu, 16 Apr 2026 01:44:19 -0400 Subject: [PATCH 2/2] fix: update deploy workflow to proxy API through Pages Function --- .github/workflows/production.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index f7c9ed0..0143ffd 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -110,7 +110,7 @@ jobs: - name: Build React PWA run: npm run build --workspace=packages/web env: - VITE_API_URL: ${{ secrets.PROD_API_GATEWAY_URL }} + VITE_API_URL: "" VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - name: Deploy to Cloudflare Pages @@ -118,4 +118,6 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy packages/web/dist --project-name=tabby --branch=main + command: pages deploy packages/web/dist --project-name=tabby --branch=main --functions-dir=packages/web/functions + env: + API_URL: ${{ secrets.PROD_API_GATEWAY_URL }}