From d9a10b7d68cb929a057c0695b7199d11f3ed629e Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 2 Jul 2026 10:03:11 +0300 Subject: [PATCH] feat(supabase): add plugins option to createClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a plugins option to createClient plus a co-located defineSupabasePlugin helper. Each plugin contributes one typed namespace at supabase., inferred from the plugins array via a recursive PluginNamespaces type — mirroring the PluginsCtx pattern from supabase/server#88 and the Entry pattern from supabase/web-middleware#9. - overload 1 keeps today's exact createClient signature (plugins?: never forces correct overload resolution); overload 2 infers the plugins tuple and returns SupabaseClient & PluginNamespaces - namespaces attach at the end of the SupabaseClient constructor so plugin factories receive a fully initialized client; collisions with existing client members or duplicate plugin names throw - scope: name + client namespace only; the events bus from the RFC is a follow-up (SDK-1163) Co-Authored-By: Claude Fable 5 --- .../core/supabase-js/src/SupabaseClient.ts | 9 ++ packages/core/supabase-js/src/index.ts | 59 ++++++++-- packages/core/supabase-js/src/lib/helpers.ts | 4 +- packages/core/supabase-js/src/lib/plugins.ts | 99 +++++++++++++++++ packages/core/supabase-js/src/lib/types.ts | 24 ++++ .../supabase-js/test/types/index.test-d.ts | 70 ++++++++++++ .../supabase-js/test/unit/plugins.test.ts | 105 ++++++++++++++++++ 7 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 packages/core/supabase-js/src/lib/plugins.ts create mode 100644 packages/core/supabase-js/test/unit/plugins.test.ts diff --git a/packages/core/supabase-js/src/SupabaseClient.ts b/packages/core/supabase-js/src/SupabaseClient.ts index a38798d63b..47d521f8a1 100644 --- a/packages/core/supabase-js/src/SupabaseClient.ts +++ b/packages/core/supabase-js/src/SupabaseClient.ts @@ -26,6 +26,7 @@ import { validateSupabaseUrl, type ResolvedSupabaseClientOptions, } from './lib/helpers' +import { attachPlugins } from './lib/plugins' import { SupabaseAuthClient } from './lib/SupabaseAuthClient' import type { Fetch, @@ -395,6 +396,14 @@ export default class SupabaseClient< if (!settings.accessToken) { this._listenForAuthEvents() } + + // Plugins attach last so their factories see a fully initialized client. + // Read from `options` (not `settings`): applySettingDefaults builds a + // fresh object from known keys and would drop `plugins` — same reason + // storage above reads `options?.storage`. + if (options?.plugins?.length) { + attachPlugins(this, options.plugins) + } } /** diff --git a/packages/core/supabase-js/src/index.ts b/packages/core/supabase-js/src/index.ts index fb914e3424..f39ef202bf 100644 --- a/packages/core/supabase-js/src/index.ts +++ b/packages/core/supabase-js/src/index.ts @@ -1,4 +1,5 @@ import SupabaseClient from './SupabaseClient' +import type { AnySupabasePlugin, PluginNamespaces } from './lib/plugins' import type { SupabaseClientOptions } from './lib/types' export * from '@supabase/auth-js' @@ -32,6 +33,8 @@ export type { QueryError, DatabaseWithoutInternals, } from './lib/types' +export { defineSupabasePlugin } from './lib/plugins' +export type { SupabasePlugin, AnySupabasePlugin, PluginNamespaces } from './lib/plugins' /** * Creates a new Supabase Client. @@ -44,7 +47,7 @@ export type { * const { data, error } = await supabase.from('profiles').select('*') * ``` */ -export const createClient = < +export function createClient< Database = any, SchemaNameOrClientOptions extends | (string & keyof Omit) @@ -60,13 +63,53 @@ export const createClient = < >( supabaseUrl: string, supabaseKey: string, - options?: SupabaseClientOptions -): SupabaseClient => { - return new SupabaseClient( - supabaseUrl, - supabaseKey, - options - ) + options?: SupabaseClientOptions & { plugins?: never } +): SupabaseClient +/** + * Creates a new Supabase Client with plugins. Each plugin contributes a + * typed namespace at `supabase.`, inferred from the `plugins` array. + * + * Type note: if you pass an explicit `Database` type argument, TypeScript + * cannot partially infer the rest, so plugin namespaces fall back to + * untyped. To combine an explicit `Database` with typed namespaces, pass + * the plugins tuple as the fourth type argument: + * `const plugins = [myPlugin()] as const` then + * `createClient(url, key, { plugins })`. + * + * @example Creating a Supabase client with plugins + * ```ts + * import { createClient } from '@supabase/supabase-js' + * import { guestbookPlugin } from '@example/supabase-plugin-guestbook' + * + * const supabase = createClient(url, key, { plugins: [guestbookPlugin()] }) + * await supabase.guestbook.list() // typed, inferred from the plugins array + * ``` + */ +export function createClient< + Database = any, + SchemaNameOrClientOptions extends + | (string & keyof Omit) + | { PostgrestVersion: string } = 'public' extends keyof Omit + ? 'public' + : string & keyof Omit, + SchemaName extends string & keyof Omit = + SchemaNameOrClientOptions extends string & keyof Omit + ? SchemaNameOrClientOptions + : 'public' extends keyof Omit + ? 'public' + : string & keyof Omit, '__InternalSupabase'>, + const Plugins extends readonly AnySupabasePlugin[] = readonly AnySupabasePlugin[], +>( + supabaseUrl: string, + supabaseKey: string, + options: SupabaseClientOptions & { plugins: Plugins } +): SupabaseClient & PluginNamespaces +export function createClient( + supabaseUrl: string, + supabaseKey: string, + options?: SupabaseClientOptions +): any { + return new SupabaseClient(supabaseUrl, supabaseKey, options) } // Check for Node.js <= 20 deprecation diff --git a/packages/core/supabase-js/src/lib/helpers.ts b/packages/core/supabase-js/src/lib/helpers.ts index 767190ec89..c1b04af1a0 100644 --- a/packages/core/supabase-js/src/lib/helpers.ts +++ b/packages/core/supabase-js/src/lib/helpers.ts @@ -21,9 +21,11 @@ export function ensureTrailingSlash(url: string): string { export const isBrowser = () => typeof window !== 'undefined' +// `plugins` is excluded: it is not a defaultable setting — the client +// constructor reads it from the raw options and attaches namespaces directly. export type ResolvedSupabaseClientOptions = Omit< Required>, - 'tracePropagation' + 'tracePropagation' | 'plugins' > & { tracePropagation: TracePropagationOptions } diff --git a/packages/core/supabase-js/src/lib/plugins.ts b/packages/core/supabase-js/src/lib/plugins.ts new file mode 100644 index 0000000000..097441e32c --- /dev/null +++ b/packages/core/supabase-js/src/lib/plugins.ts @@ -0,0 +1,99 @@ +import type SupabaseClient from '../SupabaseClient' + +/** + * A Supabase client plugin. Contributes one typed namespace to the client, + * attached at `supabase.`. + * + * Create plugins with {@link defineSupabasePlugin} so `Name` and `Namespace` + * are inferred from the declaration. + */ +export interface SupabasePlugin { + /** + * The namespace key the plugin contributes: `supabase.`. + * + * Must not collide with an existing client member (`auth`, `from`, `rpc`, + * `storage`, `functions`, `realtime`, `schema`, `channel`, ...) or with + * another plugin on the same client — collisions throw at construction. + */ + name: Name + /** + * Factory for the namespace. Receives the fully initialized client, so it + * can call `client.functions.invoke(...)`, `client.from(...)`, etc. + */ + client: (client: SupabaseClient) => Namespace +} + +export type AnySupabasePlugin = SupabasePlugin + +/** + * Accumulates each plugin's namespace onto the client type: + * `[SupabasePlugin<'stripe', S>, SupabasePlugin<'guestbook', G>]` → + * `{ stripe: S } & { guestbook: G }`. + * + * The non-tuple fallback is deliberately `object` (not an index signature), + * so an untyped plugins array never swallows typos on the client. + */ +export type PluginNamespaces = + Plugins extends readonly [ + SupabasePlugin, + ...infer Rest, + ] + ? Rest extends readonly AnySupabasePlugin[] + ? { [K in Name]: Namespace } & PluginNamespaces + : { [K in Name]: Namespace } + : object + +/** + * Declares a Supabase client plugin. + * + * Identity at runtime — it exists to capture the literal `name` and the + * namespace type, so `createClient(url, key, { plugins: [myPlugin()] })` + * infers `supabase.` with no manual annotations. + * + * @example Declaring and using a plugin + * ```ts + * import { createClient, defineSupabasePlugin } from '@supabase/supabase-js' + * + * const guestbookPlugin = () => + * defineSupabasePlugin({ + * name: 'guestbook', + * client: (client) => ({ + * list: () => client.functions.invoke('guestbook-mw', { method: 'GET' }), + * }), + * }) + * + * const supabase = createClient(url, key, { plugins: [guestbookPlugin()] }) + * await supabase.guestbook.list() // typed + * ``` + */ +export function defineSupabasePlugin(plugin: { + name: Name + client: (client: SupabaseClient) => Namespace +}): SupabasePlugin { + return plugin +} + +/** + * Attaches each plugin's namespace to the client, in array order. A plugin's + * factory runs after all built-in sub-clients (and any earlier plugins) are + * initialized. Throws on a missing/non-string `name` or on a collision with + * any existing client member — including prototype methods and getters via + * the `in` check. (Declared-but-unassigned fields like `changedAccessToken` + * are not own properties and thus not guarded; acceptable.) + * + * @internal + */ +export function attachPlugins(client: object, plugins: readonly AnySupabasePlugin[]): void { + for (const plugin of plugins) { + const name = plugin?.name + if (typeof name !== 'string' || !name) { + throw new Error('@supabase/supabase-js: each plugin must have a string `name`.') + } + if (name in client) { + throw new Error( + `@supabase/supabase-js: plugin "${name}" conflicts with an existing property on the Supabase client.` + ) + } + ;(client as Record)[name] = plugin.client(client as SupabaseClient) + } +} diff --git a/packages/core/supabase-js/src/lib/types.ts b/packages/core/supabase-js/src/lib/types.ts index c90e35e174..0a6caee7eb 100644 --- a/packages/core/supabase-js/src/lib/types.ts +++ b/packages/core/supabase-js/src/lib/types.ts @@ -2,6 +2,7 @@ import { GoTrueClientOptions } from '@supabase/auth-js' import { RealtimeClientOptions } from '@supabase/realtime-js' import { PostgrestError } from '@supabase/postgrest-js' import type { StorageClientOptions } from '@supabase/storage-js' +import type { AnySupabasePlugin } from './plugins' import type { GenericSchema, GenericRelationship, @@ -299,6 +300,29 @@ export type SupabaseClientOptions = { * @see https://www.w3.org/TR/trace-context/ */ tracePropagation?: TracePropagationOptions | boolean + /** + * Plugins to attach to the client. Each plugin contributes one typed + * namespace at `supabase.`, run in array order after the built-in + * sub-clients are initialized. Declare plugins with `defineSupabasePlugin`. + * + * A plugin name that collides with an existing client member or another + * plugin throws at construction. + * + * Note: for the namespaces to be typed, let `createClient` infer its type + * arguments. If you pass an explicit `Database` generic, TypeScript's + * lack of partial inference means the namespaces fall back to untyped — + * pass the plugins tuple explicitly as the fourth type argument + * (`const plugins = [...] as const`) to get both. + * + * @example With a plugin + * ```ts + * const supabase = createClient(url, key, { + * plugins: [guestbookPlugin()], + * }) + * await supabase.guestbook.list() // typed + * ``` + */ + plugins?: readonly AnySupabasePlugin[] } /** diff --git a/packages/core/supabase-js/test/types/index.test-d.ts b/packages/core/supabase-js/test/types/index.test-d.ts index 654ddb9f4c..6155d16168 100644 --- a/packages/core/supabase-js/test/types/index.test-d.ts +++ b/packages/core/supabase-js/test/types/index.test-d.ts @@ -3,6 +3,7 @@ import { PostgrestSingleResponse, SupabaseClient, createClient, + defineSupabasePlugin, DatabaseWithoutInternals, } from '../../src/index' import { Database, Json } from '../types' @@ -309,3 +310,72 @@ const supabase = createClient(URL, KEY) type CleanedPlainDatabase = DatabaseWithoutInternals expectType({} as PlainDatabase) } + +// plugins: namespace inference from the plugins array (no manual annotations) +{ + const guestbookPlugin = () => + defineSupabasePlugin({ + name: 'guestbook', + client: (client) => ({ + sign: (message: string) => Promise.resolve({ message }), + list: () => Promise.resolve([] as string[]), + }), + }) + + const c = createClient(URL, KEY, { plugins: [guestbookPlugin()] }) + expectType<(message: string) => Promise<{ message: string }>>(c.guestbook.sign) + expectType<() => Promise>(c.guestbook.list) + + // built-in surface still present on the intersection + c.from('users') + c.auth + c.functions + + // no index-signature leak: unknown keys stay compile errors + expectError(c.notAPlugin) + + // defineSupabasePlugin captures the literal name + expectType<'guestbook'>(guestbookPlugin().name) + + // two plugins accumulate both namespaces + const counterPlugin = defineSupabasePlugin({ + name: 'counter', + client: () => ({ increment: (by: number) => by + 1 }), + }) + const c2 = createClient(URL, KEY, { plugins: [guestbookPlugin(), counterPlugin] }) + expectType<(message: string) => Promise<{ message: string }>>(c2.guestbook.sign) + expectType<(by: number) => number>(c2.counter.increment) +} + +// plugins: no-plugins calls gain no phantom namespaces (overload 1 unchanged) +{ + const plain = createClient(URL, KEY) + expectError(plain.guestbook) + const plainWithOptions = createClient(URL, KEY, { db: {} }) + expectError(plainWithOptions.guestbook) +} + +// plugins: malformed plugin rejected +{ + expectError(createClient(URL, KEY, { plugins: [{ name: 'x' }] })) +} + +// plugins: partial explicit generics — Database typing intact, namespaces +// fall back to untyped (TS has no partial inference; documented caveat) +{ + const guestbookPlugin = defineSupabasePlugin({ + name: 'guestbook', + client: () => ({ list: () => Promise.resolve([] as string[]) }), + }) + + const c = createClient(URL, KEY, { plugins: [guestbookPlugin] }) + c.from('users') + expectError(c.from('some_table_that_does_not_exist')) + expectError(c.guestbook) + + // escape hatch: pass the plugins tuple as the fourth type argument + const plugins = [guestbookPlugin] as const + const c2 = createClient(URL, KEY, { plugins }) + expectType<() => Promise>(c2.guestbook.list) + expectError(c2.from('some_table_that_does_not_exist')) +} diff --git a/packages/core/supabase-js/test/unit/plugins.test.ts b/packages/core/supabase-js/test/unit/plugins.test.ts new file mode 100644 index 0000000000..23a13c984a --- /dev/null +++ b/packages/core/supabase-js/test/unit/plugins.test.ts @@ -0,0 +1,105 @@ +import { createClient, defineSupabasePlugin, SupabaseClient } from '../../src/index' + +const URL = 'http://localhost:3000' +const KEY = 'some.fake.key' + +describe('plugins', () => { + test('attaches the plugin namespace and its methods work', () => { + const greeter = defineSupabasePlugin({ + name: 'greeter', + client: () => ({ greet: (who: string) => `hello ${who}` }), + }) + + const supabase = createClient(URL, KEY, { plugins: [greeter] }) + expect(supabase.greeter).toBeDefined() + expect(supabase.greeter.greet('world')).toBe('hello world') + }) + + test('factory receives the fully initialized client itself', () => { + let received: unknown + const probe = defineSupabasePlugin({ + name: 'probe', + client: (client) => { + received = client + // built-in sub-clients are already usable inside the factory + expect(client.functions).toBeDefined() + expect(typeof client.from).toBe('function') + return {} + }, + }) + + const supabase = createClient(URL, KEY, { plugins: [probe] }) + expect(received).toBe(supabase) + }) + + test('attaches multiple plugins in array order; later factories see earlier namespaces', () => { + const order: string[] = [] + const first = defineSupabasePlugin({ + name: 'first', + client: () => { + order.push('first') + return { one: 1 } + }, + }) + const second = defineSupabasePlugin({ + name: 'second', + client: (client) => { + order.push('second') + expect((client as any).first).toEqual({ one: 1 }) + return { two: 2 } + }, + }) + + const supabase = createClient(URL, KEY, { plugins: [first, second] }) + expect(order).toEqual(['first', 'second']) + expect(supabase.first.one).toBe(1) + expect(supabase.second.two).toBe(2) + }) + + test.each(['from', 'auth', 'functions', 'storage', 'realtime', 'rpc', 'channel'])( + 'throws when a plugin name collides with the built-in "%s"', + (name) => { + const colliding = { name, client: () => ({}) } + expect(() => createClient(URL, KEY, { plugins: [colliding] })).toThrow( + `@supabase/supabase-js: plugin "${name}" conflicts with an existing property on the Supabase client.` + ) + } + ) + + test('throws on duplicate plugin names', () => { + const a = defineSupabasePlugin({ name: 'dup', client: () => ({ a: 1 }) }) + const b = defineSupabasePlugin({ name: 'dup', client: () => ({ b: 2 }) }) + expect(() => createClient(URL, KEY, { plugins: [a, b] })).toThrow( + '@supabase/supabase-js: plugin "dup" conflicts with an existing property on the Supabase client.' + ) + }) + + test('throws when a plugin has no string name', () => { + expect(() => + createClient(URL, KEY, { plugins: [{ name: '' as string, client: () => ({}) }] }) + ).toThrow('@supabase/supabase-js: each plugin must have a string `name`.') + expect(() => createClient(URL, KEY, { plugins: [{ client: () => ({}) } as any] })).toThrow( + '@supabase/supabase-js: each plugin must have a string `name`.' + ) + }) + + test('empty plugins array and omitted plugins behave identically', () => { + const baseline = createClient(URL, KEY) + const empty = createClient(URL, KEY, { plugins: [] as const }) + expect(Object.keys(empty).sort()).toEqual(Object.keys(baseline).sort()) + }) + + test('attaches plugins via the SupabaseClient constructor directly', () => { + const greeter = defineSupabasePlugin({ + name: 'greeter', + client: () => ({ greet: () => 'hi' }), + }) + const client = new SupabaseClient(URL, KEY, { plugins: [greeter] }) + expect((client as any).greeter.greet()).toBe('hi') + }) + + test('defineSupabasePlugin is an identity function', () => { + const spec = { name: 'x' as const, client: () => ({}) } + expect(defineSupabasePlugin(spec)).toBe(spec) + }) +})