* createInterfaceApi - a browser proxy store A way to generate a state store browser proxy from any remote API, designed for mojom (or extension) APIs The intention is to create something requiring minimal configuration and remove often repeated patterns to do with WebUI <-> Browser communication and - storing and caching fetch results across UI components - event listening to data updates and upadting local cache - passing through actions to UI components - mocking data for unit tests and storybook This is not intended to replace all local state management. The intention is to manage only the data that is owned by a remote (e.g. the browser) and present it to the UI. Any local-only state should still be managed by the relevant framework (i.e. React, Lit, or Svelte) or a separate state manager. However, this does add another relevant utility: Used to send an instance of an api to a tree of React components. However, since it accepts any function it can also conveniently be used to store global state for that tree, using React hooks (`useState`, `useMemo`) or derived state from values retrieved from the API. React Context is not that performant, as the whole tree will re-render when any pieces of state change, so this should be used sparingly. Instead, hooks for each individual endpoint should be used via the API instance which is passed down via Context. The API instance itself won't change and cause re-renders, but the individual hooks will cause subscriptions to be made to each piece of data. A helper to generate endpoints from an interface. Prevents having to re-declare the mojom function signature as Typescript can deduce it from the generated mojom JS. A helper to mark an interface function as an 'event'. Prevents having to re-declare the mojom function signature as Typescript can deduce it from the generated mojom JS. See `components/common/api/readme.md` for examples.
195 lines
6.5 KiB
TypeScript
195 lines
6.5 KiB
TypeScript
// Copyright (c) 2025 The Brave Authors. All rights reserved.
|
||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||
|
||
import {
|
||
ArgsOfAsyncMethod,
|
||
AsyncMethodKeys,
|
||
ResultOfAsyncMethod,
|
||
} from './api_utils'
|
||
import {
|
||
query,
|
||
QueryEndpointDefinition,
|
||
MutationEndpointDefinition,
|
||
PlaceholderQueryEndpointDefinition,
|
||
NoPlaceholderQueryEndpointDefinition,
|
||
} from './create_interface_api'
|
||
|
||
// Allow parameters to be the same as for createInterfaceAPI, but generated by the interface we know
|
||
// as much as possible.
|
||
type BaseIncomingQuery<T, K extends AsyncMethodKeys<T>, Z> = Omit<
|
||
QueryEndpointDefinition<ArgsOfAsyncMethod<T, K>, Z>,
|
||
'query' | 'response'
|
||
>
|
||
|
||
type MutationArgs<T, K extends keyof T> = T[K] extends (...args: any) => any
|
||
? Parameters<T[K]>
|
||
: never
|
||
|
||
type MutationResult<T, K extends keyof T> =
|
||
K extends AsyncMethodKeys<T> ? ResultOfAsyncMethod<T, K> : void
|
||
|
||
type BaseIncomingMutation<
|
||
T,
|
||
K extends keyof T,
|
||
Z = MutationResult<T, K>,
|
||
> = Omit<
|
||
MutationEndpointDefinition<MutationArgs<T, K>, Z>,
|
||
'mutation' | 'response'
|
||
>
|
||
|
||
type IncomingQueryDefinition<
|
||
T,
|
||
K extends AsyncMethodKeys<T>,
|
||
Z,
|
||
> = BaseIncomingQuery<T, K, Z> & {
|
||
response: (raw: ResultOfAsyncMethod<T, K>) => Z
|
||
}
|
||
|
||
type IncomingMutationDefinition<
|
||
T,
|
||
K extends keyof T,
|
||
Z = MutationResult<T, K>,
|
||
> = BaseIncomingMutation<T, K, NoInfer<Z>> & {
|
||
mutationResponse: (raw: MutationResult<T, K>) => Z
|
||
}
|
||
|
||
// Helper: every possible mapper for a key of `T`
|
||
type EndpointMapper<T, P extends keyof T, Z = any> =
|
||
| IncomingQueryDefinition<T, P extends AsyncMethodKeys<T> ? P : never, any>
|
||
| IncomingMutationDefinition<T, P, Z>
|
||
|
||
//
|
||
// Create endpoints generated from some keys of an interface that can be used for createMojoAPI
|
||
// Call like this:
|
||
// type MyInterface = {
|
||
// getConversationEntry: (id: string) => Promise<{ entries: string[] }>
|
||
// addNewEntry: (entry: string) => Promise<{ id: string, entry: string }>
|
||
// }
|
||
//
|
||
// endpointsFor(conversationHandler as MyInterface, {
|
||
// getConversationEntry: {
|
||
// ressponse: (result) => result.entries,
|
||
// prefetchWithArgs: ['0'],
|
||
// },
|
||
// addNewEntry: {
|
||
// mutation: (result) => result.screenshots
|
||
// }
|
||
// })
|
||
//
|
||
export function endpointsFor<
|
||
T,
|
||
const MapperDefinitions extends Partial<{
|
||
[P in keyof T]: EndpointMapper<T, P>
|
||
}>,
|
||
>(
|
||
impl: T,
|
||
// Intersect `mappers` with a compile-time check so that any mismatch between
|
||
// `placeholderData` and the return value of `response` triggers an error where the
|
||
// mapper object is written.
|
||
mappers: MapperDefinitions
|
||
& AssertPlaceholderDataMatches<MapperDefinitions>
|
||
& AssertInitialDataMatches<MapperDefinitions>,
|
||
) {
|
||
// Keys that actually appear in the supplied mappers object.
|
||
type MapperKeys = keyof MapperDefinitions
|
||
type ValidKeys = Extract<MapperKeys, keyof T>
|
||
|
||
type ValidIncomingQueryDefinition<P extends keyof T, Z> =
|
||
P extends AsyncMethodKeys<T> ? IncomingQueryDefinition<T, P, Z> : never
|
||
|
||
type ResultEndpoints = {
|
||
[P in ValidKeys]: MapperDefinitions[P] extends ValidIncomingQueryDefinition<
|
||
P,
|
||
infer Z
|
||
>
|
||
? MapperDefinitions[P] extends { placeholderData: any }
|
||
? P extends AsyncMethodKeys<T>
|
||
? PlaceholderQueryEndpointDefinition<ArgsOfAsyncMethod<T, P>, Z>
|
||
: never
|
||
: P extends AsyncMethodKeys<T>
|
||
? NoPlaceholderQueryEndpointDefinition<ArgsOfAsyncMethod<T, P>, Z>
|
||
: never
|
||
: MapperDefinitions[P] extends IncomingMutationDefinition<T, P, infer Z>
|
||
? MutationEndpointDefinition<MutationArgs<T, P>, Z>
|
||
: never
|
||
}
|
||
|
||
const result = {} as ResultEndpoints
|
||
|
||
;(Object.keys(mappers) as Array<ValidKeys>).forEach((key) => {
|
||
const mapper = mappers[key]! // non-optional – we just iterated over existing keys
|
||
if ('response' in mapper) {
|
||
const endpoint = query({
|
||
...mapper,
|
||
query: async (...args: any[]) => {
|
||
const raw = await (impl[key] as (...args: any[]) => Promise<any>)(
|
||
...(args as any),
|
||
)
|
||
const mapped = mapper.response(raw)
|
||
return mapped
|
||
},
|
||
} as any)
|
||
;(result as any)[key] = endpoint
|
||
} else if ('mutationResponse' in mapper) {
|
||
const endpoint = {
|
||
...mapper,
|
||
|
||
mutation: async (...args: any[]) => {
|
||
let raw: Promise<any> | void = (
|
||
impl[key] as (...args: any[]) => Promise<any> | void
|
||
)(...(args as any))
|
||
|
||
// Repsonse type of wrapped method is
|
||
// either Promise or void (undefined).
|
||
if (raw !== undefined) {
|
||
raw = await raw
|
||
}
|
||
|
||
// void is ok if incoming result is void
|
||
return mapper.mutationResponse(raw as any)
|
||
},
|
||
} as any
|
||
;(result as any)[key] = endpoint
|
||
}
|
||
})
|
||
|
||
return result
|
||
}
|
||
|
||
// If a mapper provides both `response` and `initialData`, ensure that the
|
||
// supplied `initialData` type is assignable to the type produced by
|
||
// `response`. We purposely only check the `Init -> R` direction so that
|
||
// literal-narrower types (e.g. `'ad'` vs `string`) are accepted.
|
||
// This utility type is a bit crazy but it's a nice-to-have so that the
|
||
// typescript error is at the correct place when an initialData property
|
||
// does not have the correct type.
|
||
type AssertInitialDataMatches<M> = {
|
||
[K in keyof M]: M[K] extends { response: (...args: any[]) => infer R }
|
||
? M[K] extends { initialData?: infer Init }
|
||
? Exclude<Init, undefined> extends never
|
||
? {} // nothing to validate
|
||
: Exclude<Init, undefined> extends R
|
||
? {} // OK: initialData is assignable to R (direct)
|
||
: Exclude<Init, undefined> extends Readonly<R>
|
||
? {} // OK: initialData is assignable to Readonly<R> (e.g. readonly array)
|
||
: { __initialDataMismatch__: never } // mismatch: initialData is not the correct type
|
||
: {}
|
||
: {}
|
||
}
|
||
|
||
type AssertPlaceholderDataMatches<M> = {
|
||
[K in keyof M]: M[K] extends { response: (...args: any[]) => infer R }
|
||
? M[K] extends { placeholderData?: infer Init }
|
||
? Exclude<Init, undefined> extends never
|
||
? {} // nothing to validate
|
||
: Exclude<Init, undefined> extends R
|
||
? {} // OK: placeholderData is assignable to R (direct)
|
||
: Exclude<Init, undefined> extends Readonly<R>
|
||
? {} // OK: placeholderData is assignable to Readonly<R> (e.g. readonly array)
|
||
: { __placeholderDataMismatch__: never } // mismatch: placeholderData is not the correct type
|
||
: {}
|
||
: {}
|
||
}
|