Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/core/supabase-js/src/SupabaseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
validateSupabaseUrl,
type ResolvedSupabaseClientOptions,
} from './lib/helpers'
import { attachPlugins } from './lib/plugins'
import { SupabaseAuthClient } from './lib/SupabaseAuthClient'
import type {
Fetch,
Expand Down Expand Up @@ -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)
}
}

/**
Expand Down
59 changes: 51 additions & 8 deletions packages/core/supabase-js/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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.
Expand All @@ -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<Database, '__InternalSupabase'>)
Expand All @@ -60,13 +63,53 @@ export const createClient = <
>(
supabaseUrl: string,
supabaseKey: string,
options?: SupabaseClientOptions<SchemaName>
): SupabaseClient<Database, SchemaNameOrClientOptions, SchemaName> => {
return new SupabaseClient<Database, SchemaNameOrClientOptions, SchemaName>(
supabaseUrl,
supabaseKey,
options
)
options?: SupabaseClientOptions<SchemaName> & { plugins?: never }
): SupabaseClient<Database, SchemaNameOrClientOptions, SchemaName>
/**
* Creates a new Supabase Client with plugins. Each plugin contributes a
* typed namespace at `supabase.<name>`, 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<Database, 'public', 'public', typeof plugins>(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<Database, '__InternalSupabase'>)
| { PostgrestVersion: string } = 'public' extends keyof Omit<Database, '__InternalSupabase'>
? 'public'
: string & keyof Omit<Database, '__InternalSupabase'>,
SchemaName extends string & keyof Omit<Database, '__InternalSupabase'> =
SchemaNameOrClientOptions extends string & keyof Omit<Database, '__InternalSupabase'>
? SchemaNameOrClientOptions
: 'public' extends keyof Omit<Database, '__InternalSupabase'>
? 'public'
: string & keyof Omit<Omit<Database, '__InternalSupabase'>, '__InternalSupabase'>,
const Plugins extends readonly AnySupabasePlugin[] = readonly AnySupabasePlugin[],
>(
supabaseUrl: string,
supabaseKey: string,
options: SupabaseClientOptions<SchemaName> & { plugins: Plugins }
): SupabaseClient<Database, SchemaNameOrClientOptions, SchemaName> & PluginNamespaces<Plugins>
export function createClient(
supabaseUrl: string,
supabaseKey: string,
options?: SupabaseClientOptions<string>
): any {
return new SupabaseClient(supabaseUrl, supabaseKey, options)
}

// Check for Node.js <= 20 deprecation
Expand Down
4 changes: 3 additions & 1 deletion packages/core/supabase-js/src/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SchemaName> = Omit<
Required<SupabaseClientOptions<SchemaName>>,
'tracePropagation'
'tracePropagation' | 'plugins'
> & {
tracePropagation: TracePropagationOptions
}
Expand Down
99 changes: 99 additions & 0 deletions packages/core/supabase-js/src/lib/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type SupabaseClient from '../SupabaseClient'

/**
* A Supabase client plugin. Contributes one typed namespace to the client,
* attached at `supabase.<name>`.
*
* Create plugins with {@link defineSupabasePlugin} so `Name` and `Namespace`
* are inferred from the declaration.
*/
export interface SupabasePlugin<Name extends string = string, Namespace extends object = object> {
/**
* The namespace key the plugin contributes: `supabase.<name>`.
*
* 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<string, object>

/**
* 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 AnySupabasePlugin[]> =
Plugins extends readonly [
SupabasePlugin<infer Name extends string, infer Namespace>,
...infer Rest,
]
? Rest extends readonly AnySupabasePlugin[]
? { [K in Name]: Namespace } & PluginNamespaces<Rest>
: { [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.<name>` 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<const Name extends string, Namespace extends object>(plugin: {
name: Name
client: (client: SupabaseClient) => Namespace
}): SupabasePlugin<Name, Namespace> {
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<string, unknown>)[name] = plugin.client(client as SupabaseClient)
}
}
24 changes: 24 additions & 0 deletions packages/core/supabase-js/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -299,6 +300,29 @@ export type SupabaseClientOptions<SchemaName> = {
* @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.<name>`, 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[]
}

/**
Expand Down
70 changes: 70 additions & 0 deletions packages/core/supabase-js/test/types/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
PostgrestSingleResponse,
SupabaseClient,
createClient,
defineSupabasePlugin,
DatabaseWithoutInternals,
} from '../../src/index'
import { Database, Json } from '../types'
Expand Down Expand Up @@ -309,3 +310,72 @@ const supabase = createClient<Database>(URL, KEY)
type CleanedPlainDatabase = DatabaseWithoutInternals<PlainDatabase>
expectType<CleanedPlainDatabase>({} 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<string[]>>(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<Database>(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<Database, 'public', 'public', typeof plugins>(URL, KEY, { plugins })
expectType<() => Promise<string[]>>(c2.guestbook.list)
expectError(c2.from('some_table_that_does_not_exist'))
}
Loading
Loading