From 296075f992560f8ae3834e862227cbe8243098f1 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Wed, 15 May 2024 11:28:15 +0200 Subject: [PATCH] feat: fix `ssr` cookie handling in all edge cases --- packages/ssr/package.json | 3 +- packages/ssr/src/common.ts | 272 +++++++++++++++ packages/ssr/src/createBrowserClient.ts | 167 ++-------- packages/ssr/src/createServerClient.ts | 136 ++++---- packages/ssr/src/types.ts | 79 ++++- packages/ssr/src/utils/chunker.ts | 14 + packages/ssr/tests/common.spec.ts | 309 ++++++++++++++++++ .../ssr/tsup.config.bundled_i6zwd190p1m.mjs | 125 ++++--- 8 files changed, 813 insertions(+), 292 deletions(-) create mode 100644 packages/ssr/src/common.ts create mode 100644 packages/ssr/tests/common.spec.ts diff --git a/packages/ssr/package.json b/packages/ssr/package.json index b39acd7c..f33310ea 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -35,8 +35,7 @@ }, "homepage": "https://github.com/supabase/auth-helpers#readme", "dependencies": { - "cookie": "^0.5.0", - "ramda": "^0.29.0" + "cookie": "^0.5.0" }, "devDependencies": { "@supabase/supabase-js": "2.42.0", diff --git a/packages/ssr/src/common.ts b/packages/ssr/src/common.ts new file mode 100644 index 00000000..255486f6 --- /dev/null +++ b/packages/ssr/src/common.ts @@ -0,0 +1,272 @@ +import { parse, serialize } from 'cookie'; + +import { + DEFAULT_COOKIE_OPTIONS, + combineChunks, + createChunks, + deleteChunks, + isBrowser, + isChunkLike +} from './utils'; + +import type { + CookieMethods, + CookieMethodsBrowser, + CookieOptions, + CookieOptionsWithName, + GetAllCookies, + SetAllCookies +} from './types'; + +/** + * Creates a storage client that handles cookies correctly for browser and + * server clients with or without properly provided cookie methods. + */ +export function createStorageFromOptions( + options: { + cookies?: CookieMethods | CookieMethodsBrowser; + cookieOptions?: CookieOptionsWithName; + } | null, + isServerClient: boolean +) { + const cookies = options?.cookies ?? null; + + const setItems: { [key: string]: string } = {}; + const removedItems: { [key: string]: boolean } = {}; + + let getAll: (keyHints: string[]) => ReturnType; + let setAll: SetAllCookies; + + if (cookies) { + if ('get' in cookies) { + // Just get is not enough, because the client needs to see what cookies are already set and unset them if necessary. To attempt to fix this behavior for most use cases, we pass "hints" which is the keys of the storage items. They are then converted to their corresponding cookie chunk names and are fetched with get. Only 5 chunks are fetched, which should be enough for the majority of use cases, but does not solve those with very large sessions. + const getWithHints = async (keyHints: string[]) => { + // optimistically find the first 5 potential chunks for the specified key + const chunkNames = keyHints.flatMap((keyHint) => [ + keyHint, + ...Array.from({ length: 5 }).map((i) => `${keyHint}.${i}`) + ]); + + const chunks: ReturnType = []; + + for (let i = 0; i < chunkNames.length; i += 1) { + const value = await cookies.get(chunkNames[i]); + + if (!value && typeof value !== 'string') { + continue; + } + + chunks.push({ name: chunkNames[i], value }); + } + + // TODO: detect and log stale chunks error + + return chunks; + }; + + getAll = async (keyHints: string[]) => await getWithHints(keyHints); + + if ('set' in cookies && 'remove' in cookies) { + setAll = async (setCookies) => { + for (let i = 0; i < setCookies.length; i += 1) { + const { name, value, options } = setCookies[i]; + + if (value) { + await cookies.set(name, value, options); + } else { + await cookies.remove(name); + } + } + }; + } else if (isServerClient) { + setAll = async () => { + console.warn( + '@supabase/ssr: createServerClient was configured without set and remove cookie methods, but the client needs to set cookies. This can lead to issues such as random logouts, early session termination or increased token refresh requests. If in NextJS, check your middleware.ts file, route handlers and server actions for correctness. Consider switching to the getAll and setAll cookie methods instead of get, set and remove which are deprecated and can be difficult to use correctly.' + ); + }; + } else { + throw new Error( + '@supabase/ssr: createBrowserClient requires configuring a getAll and setAll cookie method (deprecated: alternatively both get, set and remove can be used)' + ); + } + } else if ('getAll' in cookies) { + getAll = async () => await cookies.getAll(); + + if ('setAll' in cookies) { + setAll = cookies.setAll; + } else if (isServerClient) { + setAll = async () => { + console.warn( + '@supabase/ssr: createServerClient was configured without the setAll cookie method, but the client needs to set cookies. This can lead to issues such as random logouts, early session termination or increased token refresh requests. If in NextJS, check your middleware.ts file, route handlers and server actions for correctness.' + ); + }; + } else { + throw new Error( + '@supabase/ssr: createBrowserClient requires configuring a getAll and setAll cookie method (deprecated: alternatively both get, set and remove can be used' + ); + } + } else { + throw new Error( + '@supabase/ssr: createBrowserClient must be initialized with cookie options that specify getAll and setAll functions (deprecated: alternatively use get, set and remove)' + ); + } + } else if (!isServerClient && isBrowser()) { + // The environment is browser, so use the document.cookie API to implement getAll and setAll. + + const noHintGetAll = () => { + const parsed = parse(document.cookie); + + return Object.keys(parsed).map((name) => ({ name, value: parsed[name] })); + }; + + getAll = () => noHintGetAll(); + + setAll = (setCookies) => { + setCookies.forEach(({ name, value, options }) => { + document.cookie = serialize(name, value, options); + }); + }; + } else if (isServerClient) { + throw new Error( + '@supabase/ssr: createServerClient must be initialized with cookie options that specify getAll and setAll functions (deprecated, not recommended: alternatively use get, set and remove)' + ); + } else { + throw new Error( + '@supabase/ssr: createBrowserClient in non-browser runtimes must be initialized with cookie options that specify getAll and setAll functions (deprecated: alternatively use get, set and remove)' + ); + } + + if (!isServerClient) { + // This is the storage client to be used in browsers. It only + // works on the cookies abstraction, unlike the server client + // which only uses cookies to read the initial state. When an + // item is set, cookies are both cleared and set to values so + // that stale chunks are not left remaining. + return { + getAll, // for type consistency + setAll, // for type consistency + setItems, // for type consistency + removedItems, // for type consistency + storage: { + isServer: false, + getItem: async (key: string) => { + const allCookies = await getAll(key); + const chunkedCookie = await combineChunks(key, async (chunkName: string) => { + const cookie = allCookies?.find(({ name }) => name === chunkName) || null; + + if (!cookie) { + return null; + } + + return cookie.value; + }); + + return chunkedCookie || null; + }, + setItem: async (key: string, value: string) => { + const allCookies = await getAll(key); + const cookieNames = allCookies?.map(({ name }) => name) || []; + + const removeCookies = new Set(cookieNames.filter((name) => isChunkLike(name, key))); + + const setCookies = createChunks(key, value); + + setCookies.forEach(({ name }) => { + removeCookies.delete(name); + }); + + const removeCookieOptions = { + ...DEFAULT_COOKIE_OPTIONS, + ...options?.cookieOptions, + maxAge: 0 + }; + const setCookieOptions = { + ...DEFAULT_COOKIE_OPTIONS, + ...options?.cookieOptions, + maxAge: DEFAULT_COOKIE_OPTIONS.maxAge + }; + + const allToSet = [ + ...[...removeCookies].map((name) => ({ + name, + value: '', + options: removeCookieOptions + })), + ...setCookies.map(({ name, value }) => ({ name, value, options: setCookieOptions })) + ]; + + if (allToSet.length > 0) { + await setAll(allToSet); + } + }, + removeItem: async (key: string) => { + const allCookies = await getAll(key); + const cookieNames = allCookies?.map(({ name }) => name) || []; + const removeCookies = cookieNames.filter((name) => isChunkLike(name, key)); + + const removeCookieOptions = { + ...DEFAULT_COOKIE_OPTIONS, + ...options?.cookieOptions, + maxAge: 0 + }; + + if (removeCookies.length > 0) { + await setAll( + removeCookies.map((name) => ({ name, value: '', options: removeCookieOptions })) + ); + } + } + } + }; + } + + // This is the server client. It only uses getAll to read the initial + // state. Any subsequent changes to the items is persisted in the + // setItems and removedItems objects. createServerClient *must* use + // getAll, setAll and the values in setItems and removedItems to + // persist the changes *at once* when appropriate (usually only when + // the TOKEN_REFRESHED, USER_UPDATED or SIGNED_OUT events are fired by + // the Supabase Auth client). + return { + getAll, + setAll, + setItems, + removedItems, + storage: { + // to signal to the libraries that these cookies are + // coming from a server environment and their value + // should not be trusted + isServer: true, + getItem: async (key: string) => { + if (typeof setItems[key] === 'string') { + return setItems[key]; + } + + if (removedItems[key]) { + return null; + } + + const allCookies = await cookies.getAll(); + const chunkedCookie = await combineChunks(key, async (chunkName: string) => { + const cookie = allCookies?.find(({ name }) => name === chunkName) || null; + + if (!cookie) { + return null; + } + + return cookie.value; + }); + + return chunkedCookie || null; + }, + setItem: async (key: string, value: string) => { + setItems[key] = value; + delete removedItems[key]; + }, + removeItem: async (key: string) => { + delete setItems[key]; + removedItems[key] = true; + } + } + }; +} diff --git a/packages/ssr/src/createBrowserClient.ts b/packages/ssr/src/createBrowserClient.ts index be6243c7..4f629fd4 100644 --- a/packages/ssr/src/createBrowserClient.ts +++ b/packages/ssr/src/createBrowserClient.ts @@ -1,11 +1,11 @@ import { createClient } from '@supabase/supabase-js'; -import { mergeDeepRight } from 'ramda'; import { DEFAULT_COOKIE_OPTIONS, combineChunks, createChunks, deleteChunks, - isBrowser + isBrowser, + isChunkLike } from './utils'; import { parse, serialize } from 'cookie'; @@ -14,7 +14,14 @@ import type { GenericSchema, SupabaseClientOptions } from '@supabase/supabase-js/dist/module/lib/types'; -import type { CookieMethods, CookieOptionsWithName } from './types'; +import type { + CookieMethodsBrowser, + CookieOptions, + CookieOptionsWithName, + GetAllCookies, + SetAllCookies +} from './types'; +import { createStorageFromOptions } from './common'; let cachedBrowserClient: SupabaseClient | undefined; @@ -30,164 +37,46 @@ export function createBrowserClient< supabaseUrl: string, supabaseKey: string, options?: SupabaseClientOptions & { - cookies?: CookieMethods; + cookies?: CookieMethodsBrowser; cookieOptions?: CookieOptionsWithName; isSingleton?: boolean; } ) { + if ((options?.isSingleton || isBrowser()) && cachedBrowserClient) { + return cachedBrowserClient; + } + if (!supabaseUrl || !supabaseKey) { throw new Error( - `Your project's URL and Key are required to create a Supabase client!\n\nCheck your Supabase project's API settings to find these values\n\nhttps://supabase.com/dashboard/project/_/settings/api` + `@supabase/ssr: Your project's URL and Key are required to create a Supabase client!\n\nCheck your Supabase project's API settings to find these values\n\nhttps://supabase.com/dashboard/project/_/settings/api` ); } - let cookies: CookieMethods = {}; - let isSingleton = true; - let cookieOptions: CookieOptionsWithName | undefined; - let userDefinedClientOptions; - - if (options) { - ({ cookies = {}, isSingleton = true, cookieOptions, ...userDefinedClientOptions } = options); - cookies = cookies || {}; - } - - if (cookieOptions?.name) { - userDefinedClientOptions.auth = { - ...userDefinedClientOptions.auth, - storageKey: cookieOptions.name - }; - } - - const deleteAllChunks = async (key: string) => { - await deleteChunks( - key, - async (chunkName) => { - if (typeof cookies.get === 'function') { - return await cookies.get(chunkName); - } - if (isBrowser()) { - const documentCookies = parse(document.cookie); - return documentCookies[chunkName]; - } - }, - async (chunkName) => { - if (typeof cookies.remove === 'function') { - await cookies.remove(chunkName, { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: 0 - }); - } else { - if (isBrowser()) { - document.cookie = serialize(chunkName, '', { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: 0 - }); - } - } - } - ); - }; + const { storage } = createStorageFromOptions(options || null, false); - const cookieClientOptions = { + const client = createClient(supabaseUrl, supabaseKey, { + ...options, global: { + ...options?.global, headers: { + ...options?.global?.headers, 'X-Client-Info': `${PACKAGE_NAME}/${PACKAGE_VERSION}` } }, auth: { + ...(options?.cookieOptions?.name ? { storageKey: options.cookieOptions.name } : null), + ...options?.auth, flowType: 'pkce', autoRefreshToken: isBrowser(), detectSessionInUrl: isBrowser(), persistSession: true, - storage: { - // this client is used on the browser so cookies can be trusted - isServer: false, - getItem: async (key: string) => { - const chunkedCookie = await combineChunks(key, async (chunkName) => { - if (typeof cookies.get === 'function') { - return await cookies.get(chunkName); - } - if (isBrowser()) { - const cookie = parse(document.cookie); - return cookie[chunkName]; - } - }); - return chunkedCookie; - }, - setItem: async (key: string, value: string) => { - // first remove all chunks so there is no overlap - await deleteAllChunks(key); - - const chunks = await createChunks(key, value); - - for (let i = 0; i < chunks.length; i += 1) { - const chunk = chunks[i]; - - if (typeof cookies.set === 'function') { - await cookies.set(chunk.name, chunk.value, { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: DEFAULT_COOKIE_OPTIONS.maxAge - }); - } else { - if (isBrowser()) { - document.cookie = serialize(chunk.name, chunk.value, { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: DEFAULT_COOKIE_OPTIONS.maxAge - }); - } - } - } - }, - removeItem: async (key: string) => { - if (typeof cookies.remove === 'function' && typeof cookies.get !== 'function') { - console.log( - 'Removing chunked cookie without a `get` method is not supported.\n\n\tWhen you call the `createBrowserClient` function from the `@supabase/ssr` package, make sure you declare both a `get` and `remove` method on the `cookies` object.\n\nhttps://supabase.com/docs/guides/auth/server-side/creating-a-client' - ); - return; - } - - await deleteAllChunks(key); - } - } - } - }; - - // Overwrites default client config with any user defined options - const clientOptions = mergeDeepRight( - cookieClientOptions, - userDefinedClientOptions - ) as SupabaseClientOptions; - - if (isSingleton) { - // The `Singleton` pattern is the default to simplify the instantiation - // of a Supabase client in the browser - there must only be one - - const browser = isBrowser(); - - if (browser && cachedBrowserClient) { - return cachedBrowserClient as SupabaseClient; - } - - const client = createClient( - supabaseUrl, - supabaseKey, - clientOptions - ); - - if (browser) { - // The client should only be cached in the browser - cachedBrowserClient = client; + storage } + }); - return client; + if (options?.isSingleton || isBrowser()) { + cachedBrowserClient = client; } - // This allows for multiple Supabase clients, which may be required when using - // multiple schemas. The user will be responsible for ensuring a single - // instance of Supabase is used for each schema in the browser. - return createClient(supabaseUrl, supabaseKey, clientOptions); + return client; } diff --git a/packages/ssr/src/createServerClient.ts b/packages/ssr/src/createServerClient.ts index 9dfba7e8..84f9d9ee 100644 --- a/packages/ssr/src/createServerClient.ts +++ b/packages/ssr/src/createServerClient.ts @@ -1,12 +1,13 @@ import { createClient } from '@supabase/supabase-js'; -import { mergeDeepRight } from 'ramda'; import { DEFAULT_COOKIE_OPTIONS, combineChunks, createChunks, deleteChunks, - isBrowser + isBrowser, + isChunkLike } from './utils'; +import { createStorageFromOptions } from './common'; import type { GenericSchema, @@ -36,95 +37,74 @@ export function createServerClient< ); } - const { cookies, cookieOptions, ...userDefinedClientOptions } = options; + const { storage, getAll, setAll, setItems, removedItems } = createStorageFromOptions( + options || null, + true + ); - // use the cookie name as the storageKey value if it's set - if (cookieOptions?.name) { - userDefinedClientOptions.auth = { - ...userDefinedClientOptions.auth, - storageKey: cookieOptions.name - }; - } - - const deleteAllChunks = async (key: string) => { - await deleteChunks( - key, - async (chunkName) => { - if (typeof cookies.get === 'function') { - return await cookies.get(chunkName); - } - }, - async (chunkName) => { - if (typeof cookies.remove === 'function') { - return await cookies.remove(chunkName, { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: 0 - }); - } - } - ); - }; - - const cookieClientOptions = { + const client = createClient(supabaseUrl, supabaseKey, { + ...options, global: { + ...options?.global, headers: { + ...options?.global?.headers, 'X-Client-Info': `${PACKAGE_NAME}/${PACKAGE_VERSION}` } }, auth: { + ...(options?.cookieOptions?.name ? { storageKey: options.cookieOptions.name } : null), + ...options?.auth, flowType: 'pkce', - autoRefreshToken: isBrowser(), - detectSessionInUrl: isBrowser(), + autoRefreshToken: false, + detectSessionInUrl: false, persistSession: true, - storage: { - // to signal to the libraries that these cookies are coming from a server environment and their value should not be trusted - isServer: true, - getItem: async (key: string) => { - const chunkedCookie = await combineChunks(key, async (chunkName: string) => { - if (typeof cookies.get === 'function') { - return await cookies.get(chunkName); - } - }); - return chunkedCookie; - }, - setItem: async (key: string, value: string) => { - if (typeof cookies.set === 'function') { - // first delete all chunks so that there would be no overlap - await deleteAllChunks(key); + storage + } + }); - const chunks = createChunks(key, value); + client.auth.onAuthStateChange(async (event) => { + if (event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED' || event === 'SIGNED_OUT') { + const allCookies = await getAll([ + ...(setItems ? (Object.keys(setItems) as string[]) : []), + ...(removedItems ? (Object.keys(removedItems) as string[]) : []) + ]); + const cookieNames = allCookies?.map(({ name }) => name) || []; - for (let i = 0; i < chunks.length; i += 1) { - const chunk = chunks[i]; + const removeCookies: string[] = []; - await cookies.set(chunk.name, chunk.value, { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: DEFAULT_COOKIE_OPTIONS.maxAge - }); - } - } - }, - removeItem: async (key: string) => { - if (typeof cookies.remove === 'function' && typeof cookies.get !== 'function') { - console.log( - 'Removing chunked cookie without a `get` method is not supported.\n\n\tWhen you call the `createServerClient` function from the `@supabase/ssr` package, make sure you declare both a `get` and `remove` method on the `cookies` object.\n\nhttps://supabase.com/docs/guides/auth/server-side/creating-a-client' - ); - return; - } + const setCookies = Object.keys(setItems).flatMap((itemName) => { + const removeExistingCookiesForItem = new Set( + cookieNames.filter((name) => isChunkLike(name, itemName)) + ); - await deleteAllChunks(key); - } - } - } - }; + const chunks = createChunks(itemName, setItems[itemName]); + + chunks.forEach((chunk) => { + removeExistingCookiesForItem.delete(chunk.name); + }); + + removeCookies.push(...removeExistingCookiesForItem); - // Overwrites default client config with any user defined options - const clientOptions = mergeDeepRight( - cookieClientOptions, - userDefinedClientOptions - ) as SupabaseClientOptions; + return chunks; + }); + + const removeCookieOptions = { + ...DEFAULT_COOKIE_OPTIONS, + ...options?.cookieOptions, + maxAge: 0 + }; + const setCookieOptions = { + ...DEFAULT_COOKIE_OPTIONS, + ...options?.cookieOptions, + maxAge: DEFAULT_COOKIE_OPTIONS.maxAge + }; + + await setAll([ + ...removeCookies.map((name) => ({ name, value: '', options: removeCookieOptions })), + ...setCookies.map(({ name, value }) => ({ name, value, options: setCookieOptions })) + ]); + } + }); - return createClient(supabaseUrl, supabaseKey, clientOptions); + return client; } diff --git a/packages/ssr/src/types.ts b/packages/ssr/src/types.ts index 9959e98a..299c4cda 100644 --- a/packages/ssr/src/types.ts +++ b/packages/ssr/src/types.ts @@ -2,8 +2,77 @@ import type { CookieSerializeOptions } from 'cookie'; export type CookieOptions = Partial; export type CookieOptionsWithName = { name?: string } & CookieOptions; -export type CookieMethods = { - get?: (key: string) => Promise | string | null | undefined; - set?: (key: string, value: string, options: CookieOptions) => Promise | void; - remove?: (key: string, options: CookieOptions) => Promise | void; -}; + +export type GetCookie = ( + name: string +) => Promise | string | null | undefined; + +export type SetCookie = ( + name: string, + value: string, + options: CookieOptions +) => Promise | void; +export type RemoveCookie = (name: string) => Promise | void; + +export type GetAllCookies = () => + | Promise<{ name: string; value: string }[] | null> + | { name: string; value: string }[] + | null; + +export type SetAllCookies = ( + cookies: { name: string; value: string; options: CookieOptions }[] +) => Promise | void; + +/** + * Methods that allow access to cookies in browser-like environments. + */ +export type CookieMethodsBrowser = + | { + /** @deprecated Move to using `getAll` instead. */ + get: GetCookie; + /** @deprecated Move to using `getAll` instead. */ + set: SetCookie; + /** @deprecated Move to using `getAll` instead. */ + remove: RemoveCookie; + } + | { getAll: GetAllCookies; setAll: SetAllCookies }; + +/** + * Methods that allow access to cookies in server-side rendering environments. + */ +export type CookieMethods = + | { + /** + * @deprecated Move to using `getAll` instead. + */ + get: GetCookie; + + /** + * @deprecated Move to using `setAll` instead. + * + * Optional in certain cases only! Please read the docs on `setAll`. + * */ + set?: SetCookie; + + /** + * @deprecated Move to using `setAll` instead. + * + * Optional in certain cases only! Please read the docs on `setAll`. + * */ + remove?: RemoveCookie; + } + | { + /** + * Returns all cookies associated with the request in which the server-side client is operating. Typically (but not always) this is called only once per request / client instance. In any case, repeated calls of this function should reflect any changes done to cookies if setAll was called. + */ + getAll: GetAllCookies; + + /** + * Optional in certain cases only! Make sure you implement this method whenever possible, and omit it only in cases where the server-side client is unable to set or manipulate cookies. One common example for this is NextJS server components. + * + * Failing to provide an implementation can result in difficult to debug behavior such as: + * - Random logouts, or early session termination + * - Increased number of API calls on the `.../token?grant_type=refres_token` endpoint + */ + setAll?: SetAllCookies; + }; diff --git a/packages/ssr/src/utils/chunker.ts b/packages/ssr/src/utils/chunker.ts index 792c835d..093d7fab 100644 --- a/packages/ssr/src/utils/chunker.ts +++ b/packages/ssr/src/utils/chunker.ts @@ -5,6 +5,20 @@ interface Chunk { const MAX_CHUNK_SIZE = 3180; +const CHUNK_LIKE_REGEX = /^(.*)[.](0|[1-9][0-9]*)$/; +export function isChunkLike(cookieName: string, key: string) { + if (cookieName === key) { + return true; + } + + const chunkLike = cookieName.match(CHUNK_LIKE_REGEX); + if (chunkLike && chunkLike[1] === key) { + return true; + } + + return false; +} + /** * create chunks from a string and return an array of object */ diff --git a/packages/ssr/tests/common.spec.ts b/packages/ssr/tests/common.spec.ts new file mode 100644 index 00000000..29afb9f0 --- /dev/null +++ b/packages/ssr/tests/common.spec.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from 'vitest'; + +import { createStorageFromOptions } from '../src/common'; + +describe('createStorageFromOptions for createServerClient', () => { + describe('storage with getAll, setAll', () => { + it('should not call setAll on setItem', async () => { + let setAllCalled = false; + + const { storage, setItems, removedItems } = createStorageFromOptions({ + cookies: { + getAll: async () => { + return []; + }, + + setAll: async () => { + setAllCalled = true; + } + } + }); + + await storage.setItem('storage-key', 'value'); + + expect(setAllCalled).toBeFalsy(); + expect(setItems).toEqual({ 'storage-key': 'value' }); + expect(removedItems).toEqual({}); + }); + + it('should not call setAll on removeItem', async () => { + let setAllCalled = false; + + const { storage, setItems, removedItems } = createStorageFromOptions({ + cookies: { + getAll: async () => { + return []; + }, + + setAll: async () => { + setAllCalled = true; + } + } + }); + + await storage.removeItem('storage-key'); + + expect(setAllCalled).toBeFalsy(); + expect(setItems).toEqual({}); + expect(removedItems).toEqual({ 'storage-key': true }); + }); + + it('should not call getAll if item has already been set', async () => { + let getAllCalled = false; + + const { storage } = createStorageFromOptions({ + cookies: { + getAll: async () => { + getAllCalled = true; + + return []; + }, + + setAll: async () => {} + } + }); + + await storage.setItem('storage-key', 'value'); + + const value = await storage.getItem('storage-key'); + + expect(value).toEqual('value'); + expect(getAllCalled).toBeFalsy(); + }); + + it('should not call getAll if item has already been removed', async () => { + let getAllCalled = false; + + const { storage } = createStorageFromOptions({ + cookies: { + getAll: async () => { + getAllCalled = true; + + return []; + }, + + setAll: async () => {} + } + }); + + await storage.removeItem('storage-key'); + + const value = await storage.getItem('storage-key'); + + expect(value).toBeNull(); + expect(getAllCalled).toBeFalsy(); + }); + + it('should call getAll each time getItem is called until setItem or removeItem', async () => { + let getAllCalled = 0; + + const { storage } = createStorageFromOptions({ + cookies: { + getAll: async () => { + getAllCalled += 1; + + return []; + }, + + setAll: async () => {} + } + }); + + await storage.getItem('storage-key'); + + expect(getAllCalled).toEqual(1); + + await storage.getItem('storage-key'); + + expect(getAllCalled).toEqual(2); + + await storage.setItem('storage-key', 'value'); + + await storage.getItem('storage-key'); + + expect(getAllCalled).toEqual(2); + }); + + it('should return item value from getAll without chunks', async () => { + const { storage } = createStorageFromOptions({ + cookies: { + getAll: async () => { + return [ + { + name: 'storage-key', + value: 'value' + }, + { + name: 'other-cookie', + value: 'other-value' + }, + { + name: 'storage-key.0', + value: 'leftover-chunk-value' + } + ]; + }, + + setAll: async () => {} + } + }); + + const value = await storage.getItem('storage-key'); + + expect(value).toEqual('value'); + }); + + it('should return item value from getAll with chunks', async () => { + const { storage } = createStorageFromOptions({ + cookies: { + getAll: async () => { + return [ + { + name: 'other-cookie', + value: 'other-value' + }, + { + name: 'storage-key.0', + value: 'val' + }, + { + name: 'storage-key.1', + value: 'ue' + }, + { + name: 'storage-key.2', + value: '' + }, + { + name: 'storage-key.3', + value: 'leftover-chunk-value' + } + ]; + }, + + setAll: async () => {} + } + }); + + const value = await storage.getItem('storage-key'); + + expect(value).toEqual('value'); + }); + }); + + describe('storage with get, set, remove', () => { + it('should call get multiple times for the storage key and its chunks', async () => { + const getNames: string[] = []; + + const { storage } = createStorageFromOptions( + { + get: async (name: string) => { + getNames.push(name); + + if (name === 'storage-key') { + return 'value'; + } + + return null; + }, + set: async () => {}, + remove: async () => {} + }, + true + ); + + const value = await storage.getItem('storage-key'); + + expect(value).toEqual('value'); + + expect(getNames).toEqual([ + 'storage-key', + 'storage-key.0', + 'storage-key.1', + 'storage-key.2', + 'storage-key.3', + 'storage-key.4' + ]); + }); + + it('should reconstruct storage value from chunks', async () => { + const { storage } = createStorageFromOptions( + { + get: async (name: string) => { + if (name === 'storage-key.0') { + return 'val'; + } + + if (name === 'storage-key.1') { + return 'ue'; + } + + if (name === 'storage-key.3') { + return 'leftover-chunk-value'; + } + + return null; + }, + set: async () => {}, + remove: async () => {} + }, + true + ); + + const value = await storage.getItem('storage-key'); + + expect(value).toEqual('value'); + }); + }); + + describe('setAll when using set, remove', () => { + it('should call set and remove depending on the values sent to setAll', async () => { + const setCalls: { name: string; value: string }[] = []; + const removeCalls: string[] = []; + + const { setAll } = createStorageFromOptions( + { + get: async (name: string) => { + return null; + }, + set: async (name, value, options) => { + setCalls.push({ name, value, options }); + }, + remove: async (name) => { + removeCalls.push(name); + } + }, + true + ); + + await setAll([ + { + name: 'a', + value: 'b', + options: { maxAge: 10 } + }, + { + name: 'b', + value: 'c', + options: { maxAge: 10 } + }, + { + name: 'c', + value: '', + options: { maxAge: 0 } + } + ]); + + expect(setCalls).toEqual([ + { name: 'a', value: 'b', options: { maxAge: 10 } }, + { name: 'b', value: 'c', options: { maxAge: 10 } } + ]); + + expect(removeCalls).toEqual('c'); + }); + }); +}); + +describe('createStorageFromOptions for createBrowserClient', () => { + describe('storage', () => {}); +}); diff --git a/packages/ssr/tsup.config.bundled_i6zwd190p1m.mjs b/packages/ssr/tsup.config.bundled_i6zwd190p1m.mjs index 1a003c14..d84cdd4f 100644 --- a/packages/ssr/tsup.config.bundled_i6zwd190p1m.mjs +++ b/packages/ssr/tsup.config.bundled_i6zwd190p1m.mjs @@ -1,76 +1,65 @@ // package.json var package_default = { - name: "@supabase/ssr", - version: "0.1.0", - main: "dist/index.js", - module: "dist/index.mjs", - types: "dist/index.d.ts", - publishConfig: { - access: "public" - }, - files: [ - "dist" - ], - scripts: { - lint: "tsc", - build: "tsup", - test: "vitest run", - "test:watch": "vitest" - }, - repository: { - type: "git", - url: "git+https://github.com/supabase/auth-helpers.git" - }, - keywords: [ - "Supabase", - "Auth", - "Next.js", - "Svelte Kit", - "Remix", - "Express" - ], - author: "Supabase", - license: "MIT", - bugs: { - url: "https://github.com/supabase/auth-helpers/issues" - }, - homepage: "https://github.com/supabase/auth-helpers#readme", - dependencies: { - cookie: "^0.5.0", - ramda: "^0.29.0" - }, - devDependencies: { - "@supabase/supabase-js": "2.33.1", - "@types/cookie": "^0.5.1", - "@types/ramda": "^0.29.3", - tsconfig: "workspace:*", - tsup: "^6.7.0", - vitest: "^0.34.6" - }, - peerDependencies: { - "@supabase/supabase-js": "^2.33.1" - } + name: '@supabase/ssr', + version: '0.1.0', + main: 'dist/index.js', + module: 'dist/index.mjs', + types: 'dist/index.d.ts', + publishConfig: { + access: 'public' + }, + files: ['dist'], + scripts: { + lint: 'tsc', + build: 'tsup', + test: 'vitest run', + 'test:watch': 'vitest' + }, + repository: { + type: 'git', + url: 'git+https://github.com/supabase/auth-helpers.git' + }, + keywords: ['Supabase', 'Auth', 'Next.js', 'Svelte Kit', 'Remix', 'Express'], + author: 'Supabase', + license: 'MIT', + bugs: { + url: 'https://github.com/supabase/auth-helpers/issues' + }, + homepage: 'https://github.com/supabase/auth-helpers#readme', + dependencies: { + cookie: '^0.5.0', + ramda: '^0.29.0' + }, + devDependencies: { + '@supabase/supabase-js': '2.33.1', + '@types/cookie': '^0.5.1', + '@types/ramda': '^0.29.3', + tsconfig: 'workspace:*', + tsup: '^6.7.0', + vitest: '^0.34.6' + }, + peerDependencies: { + '@supabase/supabase-js': '^2.33.1' + } }; // tsup.config.ts var tsup = { - dts: true, - entryPoints: ["src/index.ts"], - external: ["react", "next", /^@supabase\//], - format: ["cjs", "esm"], - // inject: ['src/react-shim.js'], - // ! .cjs/.mjs doesn't work with Angular's webpack4 config by default! - legacyOutput: false, - sourcemap: true, - splitting: false, - bundle: true, - clean: true, - define: { - PACKAGE_NAME: JSON.stringify(package_default.name.replace("@", "").replace("/", "-")), - PACKAGE_VERSION: JSON.stringify(package_default.version) - } -}; -export { - tsup + dts: true, + entryPoints: ['src/index.ts'], + external: ['react', 'next', /^@supabase\//], + format: ['cjs', 'esm'], + // inject: ['src/react-shim.js'], + // ! .cjs/.mjs doesn't work with Angular's webpack4 config by default! + legacyOutput: false, + sourcemap: true, + splitting: false, + bundle: true, + clean: true, + define: { + PACKAGE_NAME: JSON.stringify(package_default.name.replace('@', '').replace('/', '-')), + PACKAGE_VERSION: JSON.stringify(package_default.version) + } }; +export { tsup }; //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsicGFja2FnZS5qc29uIiwgInRzdXAuY29uZmlnLnRzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJ7XG5cdFwibmFtZVwiOiBcIkBzdXBhYmFzZS9zc3JcIixcblx0XCJ2ZXJzaW9uXCI6IFwiMC4xLjBcIixcblx0XCJtYWluXCI6IFwiZGlzdC9pbmRleC5qc1wiLFxuXHRcIm1vZHVsZVwiOiBcImRpc3QvaW5kZXgubWpzXCIsXG5cdFwidHlwZXNcIjogXCJkaXN0L2luZGV4LmQudHNcIixcblx0XCJwdWJsaXNoQ29uZmlnXCI6IHtcblx0XHRcImFjY2Vzc1wiOiBcInB1YmxpY1wiXG5cdH0sXG5cdFwiZmlsZXNcIjogW1xuXHRcdFwiZGlzdFwiXG5cdF0sXG5cdFwic2NyaXB0c1wiOiB7XG5cdFx0XCJsaW50XCI6IFwidHNjXCIsXG5cdFx0XCJidWlsZFwiOiBcInRzdXBcIixcblx0XHRcInRlc3RcIjogXCJ2aXRlc3QgcnVuXCIsXG5cdFx0XCJ0ZXN0OndhdGNoXCI6IFwidml0ZXN0XCJcblx0fSxcblx0XCJyZXBvc2l0b3J5XCI6IHtcblx0XHRcInR5cGVcIjogXCJnaXRcIixcblx0XHRcInVybFwiOiBcImdpdCtodHRwczovL2dpdGh1Yi5jb20vc3VwYWJhc2UvYXV0aC1oZWxwZXJzLmdpdFwiXG5cdH0sXG5cdFwia2V5d29yZHNcIjogW1xuXHRcdFwiU3VwYWJhc2VcIixcblx0XHRcIkF1dGhcIixcblx0XHRcIk5leHQuanNcIixcblx0XHRcIlN2ZWx0ZSBLaXRcIixcblx0XHRcIlJlbWl4XCIsXG5cdFx0XCJFeHByZXNzXCJcblx0XSxcblx0XCJhdXRob3JcIjogXCJTdXBhYmFzZVwiLFxuXHRcImxpY2Vuc2VcIjogXCJNSVRcIixcblx0XCJidWdzXCI6IHtcblx0XHRcInVybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9zdXBhYmFzZS9hdXRoLWhlbHBlcnMvaXNzdWVzXCJcblx0fSxcblx0XCJob21lcGFnZVwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9zdXBhYmFzZS9hdXRoLWhlbHBlcnMjcmVhZG1lXCIsXG5cdFwiZGVwZW5kZW5jaWVzXCI6IHtcblx0XHRcImNvb2tpZVwiOiBcIl4wLjUuMFwiLFxuXHRcdFwicmFtZGFcIjogXCJeMC4yOS4wXCJcblx0fSxcblx0XCJkZXZEZXBlbmRlbmNpZXNcIjoge1xuXHRcdFwiQHN1cGFiYXNlL3N1cGFiYXNlLWpzXCI6IFwiMi4zMy4xXCIsXG5cdFx0XCJAdHlwZXMvY29va2llXCI6IFwiXjAuNS4xXCIsXG5cdFx0XCJAdHlwZXMvcmFtZGFcIjogXCJeMC4yOS4zXCIsXG5cdFx0XCJ0c2NvbmZpZ1wiOiBcIndvcmtzcGFjZToqXCIsXG5cdFx0XCJ0c3VwXCI6IFwiXjYuNy4wXCIsXG5cdFx0XCJ2aXRlc3RcIjogXCJeMC4zNC42XCJcblx0fSxcblx0XCJwZWVyRGVwZW5kZW5jaWVzXCI6IHtcblx0XHRcIkBzdXBhYmFzZS9zdXBhYmFzZS1qc1wiOiBcIl4yLjMzLjFcIlxuXHR9XG59XG4iLCAiY29uc3QgX19pbmplY3RlZF9maWxlbmFtZV9fID0gXCIvVXNlcnMva2FuZ21pbmd0YXkvV29yay9TdXBhYmFzZS9hdXRoLWhlbHBlcnMvcGFja2FnZXMvc3NyL3RzdXAuY29uZmlnLnRzXCI7Y29uc3QgX19pbmplY3RlZF9kaXJuYW1lX18gPSBcIi9Vc2Vycy9rYW5nbWluZ3RheS9Xb3JrL1N1cGFiYXNlL2F1dGgtaGVscGVycy9wYWNrYWdlcy9zc3JcIjtjb25zdCBfX2luamVjdGVkX2ltcG9ydF9tZXRhX3VybF9fID0gXCJmaWxlOi8vL1VzZXJzL2thbmdtaW5ndGF5L1dvcmsvU3VwYWJhc2UvYXV0aC1oZWxwZXJzL3BhY2thZ2VzL3Nzci90c3VwLmNvbmZpZy50c1wiO2ltcG9ydCB0eXBlIHsgT3B0aW9ucyB9IGZyb20gJ3RzdXAnO1xuaW1wb3J0IHBrZyBmcm9tICcuL3BhY2thZ2UuanNvbic7XG5cbmV4cG9ydCBjb25zdCB0c3VwOiBPcHRpb25zID0ge1xuXHRkdHM6IHRydWUsXG5cdGVudHJ5UG9pbnRzOiBbJ3NyYy9pbmRleC50cyddLFxuXHRleHRlcm5hbDogWydyZWFjdCcsICduZXh0JywgL15Ac3VwYWJhc2VcXC8vXSxcblx0Zm9ybWF0OiBbJ2NqcycsICdlc20nXSxcblx0Ly8gICBpbmplY3Q6IFsnc3JjL3JlYWN0LXNoaW0uanMnXSxcblx0Ly8gISAuY2pzLy5tanMgZG9lc24ndCB3b3JrIHdpdGggQW5ndWxhcidzIHdlYnBhY2s0IGNvbmZpZyBieSBkZWZhdWx0IVxuXHRsZWdhY3lPdXRwdXQ6IGZhbHNlLFxuXHRzb3VyY2VtYXA6IHRydWUsXG5cdHNwbGl0dGluZzogZmFsc2UsXG5cdGJ1bmRsZTogdHJ1ZSxcblx0Y2xlYW46IHRydWUsXG5cdGRlZmluZToge1xuXHRcdFBBQ0tBR0VfTkFNRTogSlNPTi5zdHJpbmdpZnkocGtnLm5hbWUucmVwbGFjZSgnQCcsICcnKS5yZXBsYWNlKCcvJywgJy0nKSksXG5cdFx0UEFDS0FHRV9WRVJTSU9OOiBKU09OLnN0cmluZ2lmeShwa2cudmVyc2lvbilcblx0fVxufTtcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBQTtBQUFBLEVBQ0MsTUFBUTtBQUFBLEVBQ1IsU0FBVztBQUFBLEVBQ1gsTUFBUTtBQUFBLEVBQ1IsUUFBVTtBQUFBLEVBQ1YsT0FBUztBQUFBLEVBQ1QsZUFBaUI7QUFBQSxJQUNoQixRQUFVO0FBQUEsRUFDWDtBQUFBLEVBQ0EsT0FBUztBQUFBLElBQ1I7QUFBQSxFQUNEO0FBQUEsRUFDQSxTQUFXO0FBQUEsSUFDVixNQUFRO0FBQUEsSUFDUixPQUFTO0FBQUEsSUFDVCxNQUFRO0FBQUEsSUFDUixjQUFjO0FBQUEsRUFDZjtBQUFBLEVBQ0EsWUFBYztBQUFBLElBQ2IsTUFBUTtBQUFBLElBQ1IsS0FBTztBQUFBLEVBQ1I7QUFBQSxFQUNBLFVBQVk7QUFBQSxJQUNYO0FBQUEsSUFDQTtBQUFBLElBQ0E7QUFBQSxJQUNBO0FBQUEsSUFDQTtBQUFBLElBQ0E7QUFBQSxFQUNEO0FBQUEsRUFDQSxRQUFVO0FBQUEsRUFDVixTQUFXO0FBQUEsRUFDWCxNQUFRO0FBQUEsSUFDUCxLQUFPO0FBQUEsRUFDUjtBQUFBLEVBQ0EsVUFBWTtBQUFBLEVBQ1osY0FBZ0I7QUFBQSxJQUNmLFFBQVU7QUFBQSxJQUNWLE9BQVM7QUFBQSxFQUNWO0FBQUEsRUFDQSxpQkFBbUI7QUFBQSxJQUNsQix5QkFBeUI7QUFBQSxJQUN6QixpQkFBaUI7QUFBQSxJQUNqQixnQkFBZ0I7QUFBQSxJQUNoQixVQUFZO0FBQUEsSUFDWixNQUFRO0FBQUEsSUFDUixRQUFVO0FBQUEsRUFDWDtBQUFBLEVBQ0Esa0JBQW9CO0FBQUEsSUFDbkIseUJBQXlCO0FBQUEsRUFDMUI7QUFDRDs7O0FDaERPLElBQU0sT0FBZ0I7QUFBQSxFQUM1QixLQUFLO0FBQUEsRUFDTCxhQUFhLENBQUMsY0FBYztBQUFBLEVBQzVCLFVBQVUsQ0FBQyxTQUFTLFFBQVEsY0FBYztBQUFBLEVBQzFDLFFBQVEsQ0FBQyxPQUFPLEtBQUs7QUFBQTtBQUFBO0FBQUEsRUFHckIsY0FBYztBQUFBLEVBQ2QsV0FBVztBQUFBLEVBQ1gsV0FBVztBQUFBLEVBQ1gsUUFBUTtBQUFBLEVBQ1IsT0FBTztBQUFBLEVBQ1AsUUFBUTtBQUFBLElBQ1AsY0FBYyxLQUFLLFVBQVUsZ0JBQUksS0FBSyxRQUFRLEtBQUssRUFBRSxFQUFFLFFBQVEsS0FBSyxHQUFHLENBQUM7QUFBQSxJQUN4RSxpQkFBaUIsS0FBSyxVQUFVLGdCQUFJLE9BQU87QUFBQSxFQUM1QztBQUNEOyIsCiAgIm5hbWVzIjogW10KfQo=