From 09844bd5dbdcd74f9153818e27930a5692d07a1f Mon Sep 17 00:00:00 2001 From: Zonghao Yuan <64521992+zonghaoyuan@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:00:49 +0800 Subject: [PATCH] fix(web): migrate proxy.ts to edge middleware.ts for Cloudflare compat (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js 16 locks proxy.ts to the Node.js runtime, but OpenNext for Cloudflare rejects Node.js middleware at build time ("Node.js middleware is not currently supported", build.js exit 1). Rename to middleware.ts with an explicit experimental-edge runtime so the Supabase SSR cookie refresh runs on edge and stays deployable to both Vercel and Workers. Supabase SSR only uses Web APIs (fetch, cookies), so it is edge-compatible; the core getUser() refresh logic is unchanged. The matcher excludes static assets by file extension (not by "contains a dot") so future dotted dynamic routes (e.g. /u/john.doe) still get the cookie refresh. getUser() is wrapped in try/catch so a transient network error (rethrown by @supabase/auth-js) doesn't 500 or crash the page request — the cookie simply isn't refreshed that round. Note: runtime must be "experimental-edge", not "edge". Next.js 16 routes the root middleware file through the pages-router static-info path, where runtime "edge" throws E1015 at build ("Use runtime 'experimental-edge' instead"). "experimental-edge" only warns; both are treated as edge by isEdgeRuntime(). Verified: pnpm typecheck, pnpm build (Vercel), pnpm build:cf (Cloudflare — Bundling middleware function -> OpenNext build complete, node-middleware guard no longer fires). --- middleware.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++ proxy.ts | 31 ------------------------- 2 files changed, 64 insertions(+), 31 deletions(-) create mode 100644 middleware.ts delete mode 100644 proxy.ts diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..fd124d5 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,64 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { createServerClient } from "@supabase/ssr"; + +// Next.js 16 deprecated `middleware` in favor of `proxy`, but `proxy` is locked +// to the Node.js runtime. OpenNext for Cloudflare rejects Node.js middleware at +// build time ("Node.js middleware is not currently supported"), so we keep the +// `middleware` convention with an explicit edge runtime to stay deployable to +// both Vercel and Cloudflare Workers. Revisit once OpenNext supports Node.js +// middleware or `proxy` allows the edge runtime. +export async function middleware(request: NextRequest) { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; + if (!supabaseUrl || !supabaseKey) return NextResponse.next(); + + let response = NextResponse.next({ request }); + const supabase = createServerClient(supabaseUrl, supabaseKey, { + cookies: { + getAll: () => request.cookies.getAll(), + setAll: (cookiesToSet) => { + for (const { name, value } of cookiesToSet) { + request.cookies.set(name, value); + } + response = NextResponse.next({ request }); + for (const { name, value, options } of cookiesToSet) { + response.cookies.set(name, value, options); + } + }, + }, + }); + + // Must await: getUser() triggers the token refresh, and the refreshed + // cookies are written to `response` via the setAll callback above. Returning + // before it resolves can drop the refreshed session cookie. + // getUser() returns auth errors (expired/invalid token) as { error } but + // rethrows non-auth errors (e.g. fetch failures when Supabase is + // unreachable). Swallow those so a transient network blip doesn't 500 or + // crash the whole page request — the cookie simply isn't refreshed this + // round and retries on the next request. + try { + await supabase.auth.getUser(); + } catch { + return response; + } + + return response; +} + +export const config = { + // edge runtime is required for Cloudflare Workers via OpenNext; the Node.js + // middleware path is rejected by its build. Supabase SSR uses only Web APIs + // (fetch, cookies), so it is edge-compatible. + matcher: [ + // Match everything except static assets. We exclude by known file + // extensions rather than "path contains a dot" so that future dotted + // dynamic routes (e.g. /u/john.doe) still get the Supabase cookie refresh. + "/((?!_next/static|_next/image|favicon.ico|icon.svg|.*\\.(?:svg|png|jpe?g|gif|webp|avif|ico|css|js|mjs|woff2?|ttf|otf|html|xml|txt|map)).*)", + ], + // NOTE: must be "experimental-edge", NOT "edge". Next.js 16 routes the + // root middleware file through the pages-router static-info path, where + // runtime "edge" throws "edge runtime for rendering is currently + // experimental. Use runtime 'experimental-edge' instead." (E1015) at build. + // "experimental-edge" only warns. Both are treated as edge by isEdgeRuntime(). + runtime: "experimental-edge", +}; diff --git a/proxy.ts b/proxy.ts deleted file mode 100644 index 8b890c0..0000000 --- a/proxy.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { createServerClient } from "@supabase/ssr"; - -export async function proxy(request: NextRequest) { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; - if (!supabaseUrl || !supabaseKey) return NextResponse.next(); - - let response = NextResponse.next({ request }); - const supabase = createServerClient(supabaseUrl, supabaseKey, { - cookies: { - getAll: () => request.cookies.getAll(), - setAll: (cookiesToSet) => { - for (const { name, value } of cookiesToSet) { - request.cookies.set(name, value); - } - response = NextResponse.next({ request }); - for (const { name, value, options } of cookiesToSet) { - response.cookies.set(name, value, options); - } - }, - }, - }); - - // Must await: getUser() triggers the token refresh, and the refreshed - // cookies are written to `response` via the setAll callback above. Returning - // before it resolves can drop the refreshed session cookie. - await supabase.auth.getUser(); - - return response; -}