diff --git a/dist/module.d.mts b/dist/module.d.mts new file mode 100644 index 00000000..b8d49222 --- /dev/null +++ b/dist/module.d.mts @@ -0,0 +1,138 @@ +import * as _nuxt_schema from '@nuxt/schema'; +import { CookieOptions } from 'nuxt/app'; +import { SupabaseClientOptions } from '@supabase/supabase-js'; + +declare module '@nuxt/schema' { + interface PublicRuntimeConfig { + supabase: { + url: string; + key: string; + redirect: boolean; + redirectOptions: RedirectOptions; + cookieName: string; + cookiePrefix: string; + useSsrCookies: boolean; + cookieOptions: CookieOptions; + types: string | false; + clientOptions: SupabaseClientOptions; + }; + } +} +interface RedirectOptions { + login: string; + callback: string; + include?: string[]; + exclude?: string[]; + /** + * @deprecated Use `saveRedirectToCookie` instead. + */ + cookieRedirect?: boolean; + /** + * If true, when automatically redirected the redirect path will be saved to a cookie, allowing retrieval later with the `useSupabaseRedirect` composable. + * @default false + */ + saveRedirectToCookie?: boolean; +} + +interface ModuleOptions { + /** + * Supabase API URL + * @default process.env.SUPABASE_URL + * @example 'https://*.supabase.co' + * @type string + * @docs https://supabase.com/docs/reference/javascript/initializing#parameters + */ + url: string; + /** + * Supabase Client publishable API Key (previously known as 'anon key') + * @default process.env.SUPABASE_KEY + * @example '123456789' + * @type string + * @docs https://supabase.com/docs/reference/javascript/initializing#parameters + */ + key: string; + /** + * Supabase Legacy 'service_role' key (deprecated) + * @default process.env.SUPABASE_SERVICE_KEY + * @example '123456789' + * @type string + * @docs https://supabase.com/docs/reference/javascript/initializing#parameters + * @deprecated Use `secretKey` instead. Will be removed in a future version. + */ + serviceKey: string; + /** + * Supabase Secret key + * @default process.env.SUPABASE_SECRET_KEY + * @example '123456789' + * @type string + * @docs https://supabase.com/blog/jwt-signing-keys + */ + secretKey: string; + /** + * Redirect automatically to login page if user is not authenticated + * @default `true` + * @type boolean + */ + redirect?: boolean; + /** + * Redirection options, set routes for login and callback redirect + * @default + * { + login: '/login', + callback: '/confirm', + exclude: [], + } + * @type RedirectOptions + */ + redirectOptions?: RedirectOptions; + /** + * Cookie name used for storing the redirect path when using the `redirect` option, added in front of `-redirect-path` to form the full cookie name e.g. `sb-redirect-path` + * @default 'sb' + * @type string + * @deprecated Use `cookiePrefix` instead. + */ + cookieName?: string; + /** + * The prefix used for all supabase cookies, and the redirect cookie. + * @default The default storage key from the supabase-js client. + * @type string + */ + cookiePrefix?: string; + /** + * If true, the supabase client will use cookies to store the session, allowing the session to be used from the server in ssr mode. + * Some `clientOptions` are not configurable when this is enabled. See the docs for more details. + * + * If false, the server will not be able to access the session. + * @default true + * @type boolean + */ + useSsrCookies?: boolean; + /** + * Cookie options + * @default { + maxAge: 60 * 60 * 8, + sameSite: 'lax', + secure: true, + } + * @type CookieOptions + * @docs https://nuxt.com/docs/api/composables/use-cookie#options + */ + cookieOptions?: CookieOptions; + /** + * Path to Supabase database type definitions file + * @default '~/types/database.types.ts' + * @type string + */ + types?: string | false; + /** + * Supabase client options (overrides default options from `@supabase/ssr`) + * @default { } + * @type object + * @docs https://supabase.com/docs/reference/javascript/initializing#parameters + */ + clientOptions?: SupabaseClientOptions; +} +declare const _default: _nuxt_schema.NuxtModule; + +export { _default as default }; +export type { ModuleOptions, RedirectOptions }; diff --git a/dist/module.json b/dist/module.json new file mode 100644 index 00000000..09758f8a --- /dev/null +++ b/dist/module.json @@ -0,0 +1,12 @@ +{ + "name": "@nuxtjs/supabase", + "configKey": "supabase", + "compatibility": { + "nuxt": ">=3.0.0" + }, + "version": "2.0.3", + "builder": { + "@nuxt/module-builder": "1.0.2", + "unbuild": "3.6.0" + } +} \ No newline at end of file diff --git a/dist/module.mjs b/dist/module.mjs new file mode 100644 index 00000000..48d48161 --- /dev/null +++ b/dist/module.mjs @@ -0,0 +1,164 @@ +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; +import { defu } from 'defu'; +import { relative } from 'pathe'; +import { defineNuxtModule, useLogger, createResolver, addPlugin, addImportsDir, addTemplate, extendViteConfig } from '@nuxt/kit'; + +const module = defineNuxtModule({ + meta: { + name: "@nuxtjs/supabase", + configKey: "supabase", + compatibility: { + nuxt: ">=3.0.0" + } + }, + defaults: { + url: process.env.SUPABASE_URL, + key: process.env.SUPABASE_KEY, + serviceKey: process.env.SUPABASE_SERVICE_KEY, + secretKey: process.env.SUPABASE_SECRET_KEY, + redirect: true, + redirectOptions: { + login: "/login", + callback: "/confirm", + exclude: [], + cookieRedirect: false, + saveRedirectToCookie: false + }, + cookieName: "sb", + cookiePrefix: void 0, + useSsrCookies: true, + cookieOptions: { + maxAge: 60 * 60 * 8, + sameSite: "lax", + secure: true + }, + types: "~/types/database.types.ts", + clientOptions: {} + }, + setup(options, nuxt) { + const logger = useLogger("@nuxt/supabase"); + const { resolve, resolvePath } = createResolver(import.meta.url); + nuxt.options.runtimeConfig.public.supabase = defu(nuxt.options.runtimeConfig.public.supabase, { + url: options.url, + key: options.key, + redirect: options.redirect, + redirectOptions: options.redirectOptions, + cookieName: options.cookieName, + cookiePrefix: options.cookiePrefix, + useSsrCookies: options.useSsrCookies, + cookieOptions: options.cookieOptions, + clientOptions: options.clientOptions + }); + nuxt.options.runtimeConfig.supabase = defu(nuxt.options.runtimeConfig.supabase || {}, { + serviceKey: options.serviceKey, + secretKey: options.secretKey + }); + const finalUrl = nuxt.options.runtimeConfig.public.supabase.url; + if (!finalUrl) { + logger.warn("Missing supabase url, set it either in `nuxt.config.ts` or via env variable"); + } else { + try { + const defaultStorageKey = `sb-${new URL(finalUrl).hostname.split(".")[0]}-auth-token`; + const currentPrefix = nuxt.options.runtimeConfig.public.supabase.cookiePrefix; + nuxt.options.runtimeConfig.public.supabase.cookiePrefix = currentPrefix || defaultStorageKey; + } catch (error) { + logger.error( + `Invalid Supabase URL: "${finalUrl}". Please provide a valid URL (e.g., https://example.supabase.co or http://localhost:5432)`, + error + ); + const currentPrefix = nuxt.options.runtimeConfig.public.supabase.cookiePrefix; + nuxt.options.runtimeConfig.public.supabase.cookiePrefix = currentPrefix || "sb-auth-token"; + if (!nuxt.options.dev) { + throw new Error("Invalid Supabase URL configuration"); + } + } + } + if (!nuxt.options.runtimeConfig.public.supabase.key) { + logger.warn("Missing supabase publishable key, set it either in `nuxt.config.ts` or via env variable"); + } + if (nuxt.options.runtimeConfig.public.supabase.redirectOptions.cookieRedirect) { + logger.warn("The `cookieRedirect` option is deprecated, use `saveRedirectToCookie` instead."); + } + if (nuxt.options.runtimeConfig.public.supabase.cookieName != "sb") { + logger.warn("The `cookieName` option is deprecated, use `cookiePrefix` instead."); + } + const supabaseConfig = nuxt.options.runtimeConfig.supabase; + const hasServiceKey = !!supabaseConfig?.serviceKey; + const hasSecretKey = !!supabaseConfig?.secretKey; + if (hasServiceKey && !hasSecretKey) { + logger.warn("`SUPABASE_SERVICE_KEY` is deprecated and will be removed in a future version. Please migrate to `SUPABASE_SECRET_KEY` (JWT signing key). See: https://supabase.com/blog/jwt-signing-keys"); + } + const mergedOptions = nuxt.options.runtimeConfig.public.supabase; + if (mergedOptions.redirect && mergedOptions.redirectOptions.callback) { + const routeRules = {}; + routeRules[mergedOptions.redirectOptions.callback] = { ssr: false }; + nuxt.options.nitro = defu(nuxt.options.nitro, { + routeRules + }); + } + const runtimeDir = fileURLToPath(new URL("./runtime", import.meta.url)); + nuxt.options.build.transpile.push(runtimeDir); + addPlugin(resolve(runtimeDir, "plugins", "supabase.client")); + addPlugin(resolve(runtimeDir, "plugins", "supabase.server")); + if (mergedOptions.redirect) { + addPlugin(resolve(runtimeDir, "plugins", "auth-redirect")); + } + addImportsDir(resolve("./runtime/composables")); + nuxt.hook("nitro:config", (nitroConfig) => { + nitroConfig.alias = nitroConfig.alias || {}; + nitroConfig.externals = defu(typeof nitroConfig.externals === "object" ? nitroConfig.externals : {}, { + inline: [resolve("./runtime"), "@supabase/supabase-js"] + }); + nitroConfig.alias["#supabase/server"] = resolve("./runtime/server/services"); + nitroConfig.alias["#supabase/database"] = resolve(nitroConfig.buildDir, "types/supabase-database"); + }); + addTemplate({ + filename: "types/supabase.d.ts", + getContents: () => [ + "declare module '#supabase/server' {", + ` const serverSupabaseClient: typeof import('${resolve("./runtime/server/services")}').serverSupabaseClient`, + ` const serverSupabaseServiceRole: typeof import('${resolve( + "./runtime/server/services" + )}').serverSupabaseServiceRole`, + ` const serverSupabaseUser: typeof import('${resolve("./runtime/server/services")}').serverSupabaseUser`, + ` const serverSupabaseSession: typeof import('${resolve("./runtime/server/services")}').serverSupabaseSession`, + "}" + ].join("\n") + }); + addTemplate({ + filename: "types/supabase-database.d.ts", + getContents: async () => { + if (options.types) { + try { + const path = await resolvePath(options.types); + const typesPath = await resolvePath("~~/.nuxt/types/"); + if (fs.existsSync(path)) { + return `export * from '${relative(typesPath, path)}'`; + } else { + logger.warn( + `Database types configured at "${options.types}" but file not found at "${path}". Using "Database = unknown".` + ); + } + } catch (error) { + logger.error(`Failed to load Supabase database types from "${options.types}":`, error); + } + } + return `export type Database = unknown`; + } + }); + nuxt.hook("prepare:types", async (options2) => { + options2.references.push({ path: resolve(nuxt.options.buildDir, "types/supabase.d.ts") }); + }); + if (!nuxt.options.dev && !["cloudflare"].includes(process.env.NITRO_PRESET)) { + nuxt.options.build.transpile.push("websocket"); + } + extendViteConfig((config) => { + config.optimizeDeps = config.optimizeDeps || {}; + config.optimizeDeps.include = config.optimizeDeps.include || []; + config.optimizeDeps.include.push("@nuxtjs/supabase > cookie", "@nuxtjs/supabase > @supabase/postgrest-js", "@supabase/supabase-js"); + }); + } +}); + +export { module as default }; diff --git a/dist/runtime/composables/useSupabaseClient.d.ts b/dist/runtime/composables/useSupabaseClient.d.ts new file mode 100644 index 00000000..58de617c --- /dev/null +++ b/dist/runtime/composables/useSupabaseClient.d.ts @@ -0,0 +1,3 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from '#build/types/supabase-database'; +export declare const useSupabaseClient: () => SupabaseClient; diff --git a/dist/runtime/composables/useSupabaseClient.js b/dist/runtime/composables/useSupabaseClient.js new file mode 100644 index 00000000..bcbd16b4 --- /dev/null +++ b/dist/runtime/composables/useSupabaseClient.js @@ -0,0 +1,4 @@ +import { useNuxtApp } from "#imports"; +export const useSupabaseClient = () => { + return useNuxtApp().$supabase.client; +}; diff --git a/dist/runtime/composables/useSupabaseCookieRedirect.d.ts b/dist/runtime/composables/useSupabaseCookieRedirect.d.ts new file mode 100644 index 00000000..9c891e3d --- /dev/null +++ b/dist/runtime/composables/useSupabaseCookieRedirect.d.ts @@ -0,0 +1,13 @@ +import type { CookieRef } from 'nuxt/app'; +export interface UseSupabaseCookieRedirectReturn { + /** + * The reactive value of the redirect path cookie. + * Can be both read and written to. + */ + path: CookieRef; + /** + * Get the current redirect path cookie value, then clear it + */ + pluck: () => string | null; +} +export declare const useSupabaseCookieRedirect: () => UseSupabaseCookieRedirectReturn; diff --git a/dist/runtime/composables/useSupabaseCookieRedirect.js b/dist/runtime/composables/useSupabaseCookieRedirect.js new file mode 100644 index 00000000..492011f8 --- /dev/null +++ b/dist/runtime/composables/useSupabaseCookieRedirect.js @@ -0,0 +1,20 @@ +import { useRuntimeConfig, useCookie } from "#imports"; +export const useSupabaseCookieRedirect = () => { + const config = useRuntimeConfig().public.supabase; + const prefix = config.redirectOptions.saveRedirectToCookie ? config.cookiePrefix : config.cookieName; + const cookie = useCookie( + `${prefix}-redirect-path`, + { + ...config.cookieOptions, + readonly: false + } + ); + return { + path: cookie, + pluck: () => { + const value = cookie.value; + cookie.value = null; + return value; + } + }; +}; diff --git a/dist/runtime/composables/useSupabaseSession.d.ts b/dist/runtime/composables/useSupabaseSession.d.ts new file mode 100644 index 00000000..4e6c6880 --- /dev/null +++ b/dist/runtime/composables/useSupabaseSession.d.ts @@ -0,0 +1,7 @@ +import type { Session } from '@supabase/supabase-js'; +import { type Ref } from '#imports'; +/** + * Reactive `Session` state from Supabase. This is initialized in both client and server plugin + * and, on the client, also updated through `onAuthStateChange` events. + */ +export declare const useSupabaseSession: () => Ref | null>; diff --git a/dist/runtime/composables/useSupabaseSession.js b/dist/runtime/composables/useSupabaseSession.js new file mode 100644 index 00000000..3f0059f8 --- /dev/null +++ b/dist/runtime/composables/useSupabaseSession.js @@ -0,0 +1,2 @@ +import { useState } from "#imports"; +export const useSupabaseSession = () => useState("supabase_session", () => null); diff --git a/dist/runtime/composables/useSupabaseUser.d.ts b/dist/runtime/composables/useSupabaseUser.d.ts new file mode 100644 index 00000000..54f123df --- /dev/null +++ b/dist/runtime/composables/useSupabaseUser.d.ts @@ -0,0 +1,6 @@ +import type { JwtPayload } from '@supabase/supabase-js'; +import { type Ref } from '#imports'; +/** + * Reactive `User` state from Supabase. This is populated by the JWT Payload from the auth.getClaims() call. + */ +export declare const useSupabaseUser: () => Ref; diff --git a/dist/runtime/composables/useSupabaseUser.js b/dist/runtime/composables/useSupabaseUser.js new file mode 100644 index 00000000..ed0c5589 --- /dev/null +++ b/dist/runtime/composables/useSupabaseUser.js @@ -0,0 +1,2 @@ +import { useState } from "#imports"; +export const useSupabaseUser = () => useState("supabase_user", () => null); diff --git a/dist/runtime/plugins/auth-redirect.d.ts b/dist/runtime/plugins/auth-redirect.d.ts new file mode 100644 index 00000000..2abe9597 --- /dev/null +++ b/dist/runtime/plugins/auth-redirect.d.ts @@ -0,0 +1,3 @@ +import type { Plugin } from '#app'; +declare const _default: Plugin; +export default _default; diff --git a/dist/runtime/plugins/auth-redirect.js b/dist/runtime/plugins/auth-redirect.js new file mode 100644 index 00000000..1582b523 --- /dev/null +++ b/dist/runtime/plugins/auth-redirect.js @@ -0,0 +1,40 @@ +import { useSupabaseCookieRedirect } from "../composables/useSupabaseCookieRedirect.js"; +import { useSupabaseSession } from "../composables/useSupabaseSession.js"; +import { defineNuxtPlugin, addRouteMiddleware, defineNuxtRouteMiddleware, useRuntimeConfig, navigateTo } from "#imports"; +function matchesAnyPattern(path, patterns) { + return patterns.some((pattern) => { + if (!pattern) return false; + const regex = new RegExp(`^${pattern.replace(/\*/g, ".*")}$`); + return regex.test(path); + }); +} +export default defineNuxtPlugin({ + name: "auth-redirect", + setup() { + addRouteMiddleware( + "global-auth", + defineNuxtRouteMiddleware((to) => { + const config = useRuntimeConfig().public.supabase; + const { login, callback, include, exclude, cookieRedirect, saveRedirectToCookie } = config.redirectOptions; + if (include && include.length > 0) { + if (!matchesAnyPattern(to.path, include)) { + return; + } + } + const excludePatterns = [login, callback, ...exclude ?? []]; + if (matchesAnyPattern(to.path, excludePatterns)) { + return; + } + const session = useSupabaseSession(); + if (!session.value) { + if (cookieRedirect || saveRedirectToCookie) { + const redirectInfo = useSupabaseCookieRedirect(); + redirectInfo.path.value = to.fullPath; + } + return navigateTo(login); + } + }), + { global: true } + ); + } +}); diff --git a/dist/runtime/plugins/supabase.client.d.ts b/dist/runtime/plugins/supabase.client.d.ts new file mode 100644 index 00000000..22665694 --- /dev/null +++ b/dist/runtime/plugins/supabase.client.d.ts @@ -0,0 +1,6 @@ +import { type SupabaseClient } from '@supabase/supabase-js'; +import type { Plugin } from '#app'; +declare const _default: Plugin<{ + client: SupabaseClient; +}>; +export default _default; diff --git a/dist/runtime/plugins/supabase.client.js b/dist/runtime/plugins/supabase.client.js new file mode 100644 index 00000000..82fc88aa --- /dev/null +++ b/dist/runtime/plugins/supabase.client.js @@ -0,0 +1,62 @@ +import { createBrowserClient } from "@supabase/ssr"; +import { createClient } from "@supabase/supabase-js"; +import { fetchWithRetry } from "../utils/fetch-retry.js"; +import { useSupabaseSession } from "../composables/useSupabaseSession.js"; +import { useSupabaseUser } from "../composables/useSupabaseUser.js"; +import { defineNuxtPlugin, useRuntimeConfig, useNuxtApp } from "#imports"; +export default defineNuxtPlugin({ + name: "supabase", + enforce: "pre", + async setup({ provide }) { + const nuxtApp = useNuxtApp(); + const { url, key, cookieOptions, cookiePrefix, useSsrCookies, clientOptions } = useRuntimeConfig().public.supabase; + let client; + if (useSsrCookies) { + client = createBrowserClient(url, key, { + ...clientOptions, + cookieOptions: { + ...cookieOptions, + name: cookiePrefix + }, + isSingleton: true, + global: { + fetch: fetchWithRetry, + ...clientOptions.global + } + }); + } else { + client = createClient(url, key, { + ...clientOptions, + global: { + fetch: fetchWithRetry, + ...clientOptions.global + } + }); + } + provide("supabase", { client }); + const currentSession = useSupabaseSession(); + const currentUser = useSupabaseUser(); + if (!useSsrCookies) { + const { data } = await client.auth.getSession(); + if (data.session) { + currentSession.value = data.session; + } + } + nuxtApp.hook("page:start", async () => { + const { data } = await client.auth.getClaims(); + currentUser.value = data?.claims ?? null; + }); + client.auth.onAuthStateChange((_, session) => { + if (JSON.stringify(currentSession.value) !== JSON.stringify(session)) { + currentSession.value = session; + if (session?.user) { + client.auth.getClaims().then(({ data }) => { + currentUser.value = data?.claims ?? null; + }); + } else { + currentUser.value = null; + } + } + }); + } +}); diff --git a/dist/runtime/plugins/supabase.server.d.ts b/dist/runtime/plugins/supabase.server.d.ts new file mode 100644 index 00000000..466a8381 --- /dev/null +++ b/dist/runtime/plugins/supabase.server.d.ts @@ -0,0 +1,6 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Plugin } from '#app'; +declare const _default: Plugin<{ + client: SupabaseClient; +}>; +export default _default; diff --git a/dist/runtime/plugins/supabase.server.js b/dist/runtime/plugins/supabase.server.js new file mode 100644 index 00000000..5c6b7e55 --- /dev/null +++ b/dist/runtime/plugins/supabase.server.js @@ -0,0 +1,43 @@ +import { createServerClient, parseCookieHeader } from "@supabase/ssr"; +import { getHeader } from "h3"; +import { fetchWithRetry } from "../utils/fetch-retry.js"; +import { setCookies } from "../utils/cookies.js"; +import { serverSupabaseUser, serverSupabaseSession } from "../server/services/index.js"; +import { useSupabaseSession } from "../composables/useSupabaseSession.js"; +import { useSupabaseUser } from "../composables/useSupabaseUser.js"; +import { defineNuxtPlugin, useRequestEvent, useRuntimeConfig } from "#imports"; +export default defineNuxtPlugin({ + name: "supabase", + enforce: "pre", + async setup({ provide }) { + const { url, key, cookiePrefix, useSsrCookies, cookieOptions, clientOptions } = useRuntimeConfig().public.supabase; + const event = useRequestEvent(); + const client = createServerClient(url, key, { + ...clientOptions, + cookies: { + getAll: () => parseCookieHeader(getHeader(event, "Cookie") ?? ""), + setAll: (cookies) => setCookies(event, cookies) + }, + cookieOptions: { + ...cookieOptions, + name: cookiePrefix + }, + global: { + fetch: fetchWithRetry, + ...clientOptions.global + } + }); + provide("supabase", { client }); + if (useSsrCookies) { + const [ + session, + user + ] = await Promise.all([ + serverSupabaseSession(event).catch(() => null), + serverSupabaseUser(event).catch(() => null) + ]); + useSupabaseSession().value = session; + useSupabaseUser().value = user; + } + } +}); diff --git a/dist/runtime/server/services/index.d.ts b/dist/runtime/server/services/index.d.ts new file mode 100644 index 00000000..7a86aebf --- /dev/null +++ b/dist/runtime/server/services/index.d.ts @@ -0,0 +1,4 @@ +export { serverSupabaseClient } from './serverSupabaseClient.js'; +export { serverSupabaseServiceRole } from './serverSupabaseServiceRole.js'; +export { serverSupabaseUser } from './serverSupabaseUser.js'; +export { serverSupabaseSession } from './serverSupabaseSession.js'; diff --git a/dist/runtime/server/services/index.js b/dist/runtime/server/services/index.js new file mode 100644 index 00000000..30b076a5 --- /dev/null +++ b/dist/runtime/server/services/index.js @@ -0,0 +1,4 @@ +export { serverSupabaseClient } from "./serverSupabaseClient.js"; +export { serverSupabaseServiceRole } from "./serverSupabaseServiceRole.js"; +export { serverSupabaseUser } from "./serverSupabaseUser.js"; +export { serverSupabaseSession } from "./serverSupabaseSession.js"; diff --git a/dist/runtime/server/services/serverSupabaseClient.d.ts b/dist/runtime/server/services/serverSupabaseClient.d.ts new file mode 100644 index 00000000..e5e683e0 --- /dev/null +++ b/dist/runtime/server/services/serverSupabaseClient.d.ts @@ -0,0 +1,4 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import { type H3Event } from 'h3'; +import type { Database } from '#supabase/database'; +export declare const serverSupabaseClient: (event: H3Event) => Promise>; diff --git a/dist/runtime/server/services/serverSupabaseClient.js b/dist/runtime/server/services/serverSupabaseClient.js new file mode 100644 index 00000000..d397b8aa --- /dev/null +++ b/dist/runtime/server/services/serverSupabaseClient.js @@ -0,0 +1,26 @@ +import { createServerClient, parseCookieHeader } from "@supabase/ssr"; +import { getHeader } from "h3"; +import { fetchWithRetry } from "../../utils/fetch-retry.js"; +import { setCookies } from "../../utils/cookies.js"; +import { useRuntimeConfig } from "#imports"; +export const serverSupabaseClient = async (event) => { + if (!event.context._supabaseClient) { + const { url, key, cookiePrefix, cookieOptions, clientOptions: { auth = {}, global = {} } } = useRuntimeConfig(event).public.supabase; + event.context._supabaseClient = createServerClient(url, key, { + auth, + cookies: { + getAll: () => parseCookieHeader(getHeader(event, "Cookie") ?? ""), + setAll: (cookies) => setCookies(event, cookies) + }, + cookieOptions: { + ...cookieOptions, + name: cookiePrefix + }, + global: { + fetch: fetchWithRetry, + ...global + } + }); + } + return event.context._supabaseClient; +}; diff --git a/dist/runtime/server/services/serverSupabaseServiceRole.d.ts b/dist/runtime/server/services/serverSupabaseServiceRole.d.ts new file mode 100644 index 00000000..d99da535 --- /dev/null +++ b/dist/runtime/server/services/serverSupabaseServiceRole.d.ts @@ -0,0 +1,4 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { H3Event } from 'h3'; +import type { Database } from '#supabase/database'; +export declare const serverSupabaseServiceRole: (event: H3Event) => SupabaseClient; diff --git a/dist/runtime/server/services/serverSupabaseServiceRole.js b/dist/runtime/server/services/serverSupabaseServiceRole.js new file mode 100644 index 00000000..99e6f6bb --- /dev/null +++ b/dist/runtime/server/services/serverSupabaseServiceRole.js @@ -0,0 +1,26 @@ +import { createClient } from "@supabase/supabase-js"; +import { fetchWithRetry } from "../../utils/fetch-retry.js"; +import { useRuntimeConfig } from "#imports"; +export const serverSupabaseServiceRole = (event) => { + const config = useRuntimeConfig(event); + const secretKey = config.supabase.secretKey; + const serviceKey = config.supabase.serviceKey; + const url = config.public.supabase.url; + const serverKey = secretKey || serviceKey; + if (!serverKey) { + throw new Error("Missing server key. Set either `SUPABASE_SECRET_KEY` (recommended) or `SUPABASE_SERVICE_KEY` (deprecated) in your environment variables."); + } + if (!event.context._supabaseServiceRole) { + event.context._supabaseServiceRole = createClient(url, serverKey, { + auth: { + detectSessionInUrl: false, + persistSession: false, + autoRefreshToken: false + }, + global: { + fetch: fetchWithRetry + } + }); + } + return event.context._supabaseServiceRole; +}; diff --git a/dist/runtime/server/services/serverSupabaseSession.d.ts b/dist/runtime/server/services/serverSupabaseSession.d.ts new file mode 100644 index 00000000..44d6ebc7 --- /dev/null +++ b/dist/runtime/server/services/serverSupabaseSession.d.ts @@ -0,0 +1,3 @@ +import type { Session } from '@supabase/supabase-js'; +import type { H3Event } from 'h3'; +export declare const serverSupabaseSession: (event: H3Event) => Promise | null>; diff --git a/dist/runtime/server/services/serverSupabaseSession.js b/dist/runtime/server/services/serverSupabaseSession.js new file mode 100644 index 00000000..e6077b41 --- /dev/null +++ b/dist/runtime/server/services/serverSupabaseSession.js @@ -0,0 +1,11 @@ +import { createError } from "h3"; +import { serverSupabaseClient } from "../services/serverSupabaseClient.js"; +export const serverSupabaseSession = async (event) => { + const client = await serverSupabaseClient(event); + const { data: { session }, error } = await client.auth.getSession(); + if (error) { + throw createError({ statusMessage: error?.message }); + } + delete session?.user; + return session; +}; diff --git a/dist/runtime/server/services/serverSupabaseUser.d.ts b/dist/runtime/server/services/serverSupabaseUser.d.ts new file mode 100644 index 00000000..a1e284ee --- /dev/null +++ b/dist/runtime/server/services/serverSupabaseUser.d.ts @@ -0,0 +1,3 @@ +import type { JwtPayload } from '@supabase/supabase-js'; +import type { H3Event } from 'h3'; +export declare const serverSupabaseUser: (event: H3Event) => Promise; diff --git a/dist/runtime/server/services/serverSupabaseUser.js b/dist/runtime/server/services/serverSupabaseUser.js new file mode 100644 index 00000000..1a4921b1 --- /dev/null +++ b/dist/runtime/server/services/serverSupabaseUser.js @@ -0,0 +1,10 @@ +import { createError } from "h3"; +import { serverSupabaseClient } from "../services/serverSupabaseClient.js"; +export const serverSupabaseUser = async (event) => { + const client = await serverSupabaseClient(event); + const { data, error } = await client.auth.getClaims(); + if (error) { + throw createError({ statusMessage: error?.message }); + } + return data?.claims ?? null; +}; diff --git a/dist/runtime/utils/cookies.d.ts b/dist/runtime/utils/cookies.d.ts new file mode 100644 index 00000000..5af73b10 --- /dev/null +++ b/dist/runtime/utils/cookies.d.ts @@ -0,0 +1,7 @@ +import { type H3Event } from 'h3'; +import type { CookieOptions } from '#app'; +export declare function setCookies(event: H3Event, cookies: { + name: string; + value: string; + options: CookieOptions; +}[]): void; diff --git a/dist/runtime/utils/cookies.js b/dist/runtime/utils/cookies.js new file mode 100644 index 00000000..c17a752c --- /dev/null +++ b/dist/runtime/utils/cookies.js @@ -0,0 +1,14 @@ +import { setCookie } from "h3"; +export function setCookies(event, cookies) { + const response = event.node.res; + const headersWritable = () => !response.headersSent && !response.writableEnded; + if (!headersWritable()) { + return; + } + for (const { name, value, options } of cookies) { + if (!headersWritable()) { + break; + } + setCookie(event, name, value, options); + } +} diff --git a/dist/runtime/utils/fetch-retry.d.ts b/dist/runtime/utils/fetch-retry.d.ts new file mode 100644 index 00000000..e8ace42e --- /dev/null +++ b/dist/runtime/utils/fetch-retry.d.ts @@ -0,0 +1 @@ +export declare function fetchWithRetry(req: RequestInfo | URL, init?: RequestInit): Promise; diff --git a/dist/runtime/utils/fetch-retry.js b/dist/runtime/utils/fetch-retry.js new file mode 100644 index 00000000..ee9794fc --- /dev/null +++ b/dist/runtime/utils/fetch-retry.js @@ -0,0 +1,19 @@ +export async function fetchWithRetry(req, init) { + const retries = 3; + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await fetch(req, init); + } catch (error) { + if (init?.signal?.aborted) { + throw error; + } + if (attempt === retries) { + console.error(`Error fetching request ${req}`, error, init); + throw error; + } + console.warn(`Retrying fetch attempt ${attempt + 1} for request: ${req}`); + await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); + } + } + throw new Error("Unreachable code"); +} diff --git a/dist/types.d.mts b/dist/types.d.mts new file mode 100644 index 00000000..08f6bc8e --- /dev/null +++ b/dist/types.d.mts @@ -0,0 +1,3 @@ +export { default } from './module.mjs' + +export { type ModuleOptions, type RedirectOptions } from './module.mjs' diff --git a/src/runtime/plugins/supabase.client.ts b/src/runtime/plugins/supabase.client.ts index 2319e86f..7a27ed40 100644 --- a/src/runtime/plugins/supabase.client.ts +++ b/src/runtime/plugins/supabase.client.ts @@ -44,6 +44,16 @@ export default defineNuxtPlugin({ const currentSession = useSupabaseSession() const currentUser = useSupabaseUser() + // In SPA mode, restore session from storage before auth middleware runs + // This prevents a race condition where middleware checks session before it's hydrated + // See: https://github.com/nuxt-modules/supabase/issues/496 + if (!useSsrCookies) { + const { data } = await client.auth.getSession() + if (data.session) { + currentSession.value = data.session + } + } + // Populate user before each page load to ensure the user state is correctly set before the page is rendered nuxtApp.hook('page:start', async () => { const { data } = await client.auth.getClaims()