Skip to content

Commit

Permalink
Created workaround for TypeScript type parameter limitation, started …
Browse files Browse the repository at this point in the history
…adding tests for types

Also added more options for PromisifiedModule type (types still need to be reorganized and thought out)
  • Loading branch information
doseofted committed Mar 27, 2024
1 parent 558a5ee commit 487cac0
Show file tree
Hide file tree
Showing 8 changed files with 209 additions and 55 deletions.
4 changes: 2 additions & 2 deletions libs/rpc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
"build": "tsup --config build.config.js",
"check": "tsc --noEmit",
"document": "typedoc",
"test": "vitest run",
"testing": "vitest watch",
"test": "vitest run --typecheck",
"testing": "vitest watch --typecheck",
"lint": "eslint ."
},
"dependencies": {
Expand Down
15 changes: 11 additions & 4 deletions libs/rpc/src/client/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { expect, test } from "vitest"
import { createPrimClient } from "./index"

type ExampleModule = { hello: (name?: string) => Promise<string>; goodbye: () => string; anError: () => void }
type ExampleModule = {
hello: (name?: string) => Promise<string>
goodbye: () => string
anError: () => void
// what(): { is(): { this(): void } }
}
const exampleModule = {
hello(name?: string) {
return ["Hello", name].filter(given => given).join(" ")
Expand All @@ -12,9 +17,11 @@ const exampleModule = {
}

test("static import", async () => {
const client = createPrimClient<Promise<ExampleModule>, typeof exampleModule>({
module: exampleModule,
})
const options = {
module: () => Promise.resolve(exampleModule),
handleForms: false,
} as const
const client = createPrimClient<ExampleModule>(options)
// not async since module was provided locally
expect(client.hello()).toBe("Hello")
// FIXME: not implemented yet
Expand Down
23 changes: 18 additions & 5 deletions libs/rpc/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { PrimOptions, PromisifiedModule } from "../interfaces"
import {
FunctionAndForm,
type JsonHandler,
type PossibleModule,
type PrimOptions,
type PromisifiedModule,
type RemoveDynamicImport,
type RemoveFunctionWrapper,
} from "../interfaces"
import { createPrimOptions } from "../options"
import { isDefined } from "emery"
import { createMethodCatcher } from "./proxy"
import { handlePotentialPromise } from "./wrapper"
import { getUnfulfilledModule, handleLocalModule } from "./local"
import { MergeModuleMethods } from "../types/merge"

export function createPrimClient<
ModuleType extends GivenOptions["module"],
OverrideModule extends GivenOptions["module"] = never,
GivenOptions extends PrimOptions = PrimOptions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
ModuleType extends PossibleModule = never,
GivenOptions extends PrimOptions = PrimOptions<PossibleModule, JsonHandler, boolean>,
>(options?: GivenOptions) {
options = createPrimOptions<GivenOptions>(options)
const providedModule = getUnfulfilledModule(options.module)
const providedMethodPlugin = isDefined(options.methodPlugin)
const providedCallbackPlugin = isDefined(options.callbackPlugin)
// the returned client will catch all method calls given on it recursively
return createMethodCatcher<PromisifiedModule<ModuleType, OverrideModule>>({
type FinalModule = MergeModuleMethods<
PromisifiedModule<ModuleType, GivenOptions["handleForms"], true>,
PromisifiedModule<GivenOptions["module"], GivenOptions["handleForms"], false>
>
return createMethodCatcher<FinalModule>({
onMethod(rpc, next) {
// if module method was provided (and is not dynamic import), intercept call and return synchronously
if (providedModule instanceof Promise) return next
Expand Down
2 changes: 1 addition & 1 deletion libs/rpc/src/client/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function handleLocalModule(rpc: RpcCall<string, unknown[]>, options: Prim
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
if (method) {
const preprocessed = options.preRequest?.(rpc.args, rpc.method) ?? { args: rpc.args }
if (Array.isArray(preprocessed.args) && givenFormLike(preprocessed.args[0])) {
if (options.handleForms && Array.isArray(preprocessed.args) && givenFormLike(preprocessed.args[0])) {
preprocessed.args[0] = handlePossibleForm(preprocessed.args[0])
}
if ("result" in preprocessed) {
Expand Down
79 changes: 37 additions & 42 deletions libs/rpc/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,53 +119,41 @@ interface PrimWebSocketFunctionEvents {
// let d: VariableArgsFunction<typeof c>
// await d("what")

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FunctionAndForm<Args extends any[], Result> = {
(...args: Args): Result
(formLike: SubmitEvent | FormData | HTMLFormElement): Result
}
// type B<T = never> = [T] extends [never] ? true : false
// let b: B

// type PromisifiedModuleDirectWithoutOverride<
// ModuleGiven extends object,
// Recursive extends true | false = true,
// Keys extends keyof ModuleGiven = Extract<keyof ModuleGiven, string>,
// > = ConditionalExcept<
// {
// [Key in Keys]: ModuleGiven[Key] extends ((...args: infer A) => infer R) & object
// ? FunctionAndForm<A, Promise<Awaited<R>>> & PromisifiedModuleDirect<ModuleGiven[Key], false>
// : ModuleGiven[Key] extends object
// ? Recursive extends true
// ? PromisifiedModuleDirect<ModuleGiven[Key], true>
// : never
// : never
// },
// never
// >
// NOTE: ny default, assume that form arguments could be given unless explicitly disabled
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FunctionAndForm<Args extends any[], Result, F extends true | false = true> = [F] extends [false]
? {
(...args: Args): Result
}
: {
(...args: Args): Result
(formLike: SubmitEvent | FormData | HTMLFormElement): Result
}

// TODO: to support nested modules in PromisifiedModuleDirect, I need to merge returned Promise with the functions
// returned from a given function

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyFunction = (...args: any[]) => any
// NOTE: consider condition of checking `.rpc` property on function (but also remember that it may be in allow list)
type PromisifiedModuleDirect<
ModuleGiven extends object,
Recursive extends true | false = true,
ModuleOverride extends object = never,
HandleForm extends true | false = true,
Promised extends true | false = true,
Keys extends keyof ModuleGiven = Extract<keyof ModuleGiven, string>,
> = ConditionalExcept<
{
[Key in Keys]: ModuleGiven[Key] extends (...args: infer A) => infer R
? Key extends keyof ModuleOverride
? ModuleOverride[Key] extends (...args: infer A2) => infer R2
? FunctionAndForm<A2, R2> & PromisifiedModuleDirect<ModuleGiven[Key], false, ModuleOverride[Key]>
: FunctionAndForm<A, Promise<Awaited<R>>> & PromisifiedModuleDirect<ModuleGiven[Key], false>
: FunctionAndForm<A, Promise<Awaited<R>>> & PromisifiedModuleDirect<ModuleGiven[Key], false>
? FunctionAndForm<A, Promised extends true ? Promise<Awaited<R>> : R, HandleForm> &
PromisifiedModuleDirect<ModuleGiven[Key], false, HandleForm>
: ModuleGiven[Key] extends object
? Recursive extends true
? Key extends keyof ModuleOverride
? ModuleOverride[Key] extends object
? PromisifiedModuleDirect<ModuleGiven[Key], true, ModuleOverride[Key]>
: PromisifiedModuleDirect<ModuleGiven[Key], true>
: PromisifiedModuleDirect<ModuleGiven[Key], true>
: PromisifiedModuleDirect<ModuleGiven[Key], false>
? PromisifiedModuleDirect<ModuleGiven[Key], true, HandleForm>
: never
: never
},
never
Expand Down Expand Up @@ -207,28 +195,30 @@ type PromisifiedModuleDirect<
// NOTE: this is a non-recursive version of default `Awaited` type that comes with TypeScript
type PromisifiedModuleDynamicImport<
ModuleGiven extends object,
ModuleOverride extends object = never,
HandleForm extends true | false,
Promised extends true | false,
> = ModuleGiven extends object & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
then: (onfulfilled: infer F, ...args: infer _) => any
}
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
F extends (value: infer V, ...args: infer _) => any
? V extends object
? PromisifiedModuleDirect<V, true, ModuleOverride>
? PromisifiedModuleDirect<V, true, HandleForm, Promised>
: never
: never
: PromisifiedModuleDirect<ModuleGiven, true, ModuleOverride>
: PromisifiedModuleDirect<ModuleGiven, true, HandleForm, Promised>

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyFunctionReturnsPromise = (...args: any[]) => PromiseLike<any>
// If given a function that returns a promise, get the returned promise (pattern used often with dynamic imports)
export type PromisifiedModule<
Given extends object,
ModuleOverride extends object = never,
HandleForm extends true | false,
Promised extends true | false = true,
> = Given extends AnyFunctionReturnsPromise
? PromisifiedModuleDynamicImport<ReturnType<Given>, ModuleOverride>
: PromisifiedModuleDynamicImport<Given, ModuleOverride>
? PromisifiedModuleDynamicImport<ReturnType<Given>, HandleForm, Promised>
: PromisifiedModuleDynamicImport<Given, HandleForm, Promised>

export type RemoveFunctionWrapper<Given extends object> = Given extends AnyFunctionReturnsPromise
? ReturnType<Given>
Expand All @@ -248,7 +238,7 @@ export type RemoveDynamicImport<ModuleGiven> = ModuleGiven extends object & {
// The following is intended to be used to export a module used with the client
// (useful for JSDocs or usage outside of the Prim Client that provides this type)
/** Module transformed as it is done by the Prim+RPC client */
export type RpcModule<M extends object> = PromisifiedModule<M>
export type RpcModule<M extends object> = PromisifiedModule<M, true>

export interface JsonHandler {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -296,7 +286,11 @@ export type PossibleModule = object
// | Promise<Record<string, unknown>>
// | (() => Promise<Record<string, unknown>>)

export interface PrimOptions<M extends PossibleModule = object, J extends JsonHandler = JsonHandler> {
export interface PrimOptions<
M extends PossibleModule = PossibleModule,
J extends JsonHandler = JsonHandler,
F extends boolean = boolean,
> {
/**
* This option is not yet implemented.
*
Expand Down Expand Up @@ -389,7 +383,7 @@ export interface PrimOptions<M extends PossibleModule = object, J extends JsonHa
* If given function specifies a `.rpc` boolean property with a value of `true` then those functions do not need
* to be added to the allow-list.
*/
allowList?: PartialDeep<Schema<PromisifiedModule<M>, true | "idempotent">>
allowList?: PartialDeep<Schema<PromisifiedModule<M, false>, true | "idempotent">>
/**
* In JavaScript, functions are objects. Those objects can have methods. This means that functions can have methods.
*
Expand Down Expand Up @@ -428,6 +422,7 @@ export interface PrimOptions<M extends PossibleModule = object, J extends JsonHa
* then you may toggle this option `false` to prevent Prim from extracting binary data.
*/
handleBlobs?: boolean
handleForms?: F
/** Transform given arguments prior to sending RPC to server (unlike post-request hook, it must be synchronous) */
preRequest?: (args: unknown[], name: string) => { args: unknown[]; result?: unknown } | undefined
/** Transform given result prior to being returned to the RPC caller */
Expand Down
2 changes: 1 addition & 1 deletion libs/rpc/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const createBaseServerOptions = (): PrimServerOptions => ({
* @returns Options with defaults set
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createPrimOptions<OptionsType extends PrimOptions<any, any> = PrimOptions>(
export function createPrimOptions<OptionsType extends PrimOptions<any, any, any> = PrimOptions>(
options?: OptionsType,
server = false
) {
Expand Down
114 changes: 114 additions & 0 deletions libs/rpc/src/types/merge.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { expectTypeOf, test, describe } from "vitest"
import type { MergeModuleMethods } from "./merge"
import { PromisifiedModule } from "../interfaces"

describe("MergeModule merges modules", () => {
test("override all without new methods", () => {
type A = {
lorem(): string
testing: {
ipsum(): Promise<void>
}
}
type B = {
lorem(): number
testing: {
ipsum(): void
}
}
type Merged = MergeModuleMethods<A, B>
expectTypeOf<Merged>().toMatchTypeOf<B>()
})

test("with new methods", () => {
type A = {
what(): void
testing: {
lorem(): string
}
}
type B = {
testing: {
ipsum(): void
}
cool(): boolean
}
type Merged = MergeModuleMethods<A, B>
type Expected = {
what(): void
testing: {
lorem(): string
ipsum(): void
}
cool(): boolean
}
expectTypeOf<Merged>().toMatchTypeOf<Expected>()
})

test("without override", () => {
type A = {
what(): void
testing: {
lorem(): string
}
}
type B = never
type Merged = MergeModuleMethods<A, B>
expectTypeOf<Merged>().toMatchTypeOf<A>()
})

test("works without base", () => {
type A = never
type B = {
what(): void
}
type Merged = MergeModuleMethods<A, B>
expectTypeOf<Merged>().toMatchTypeOf<B>()
})
})

describe("MergeModule works with PromisifiedModule", () => {
test("With form handling", () => {
type A = PromisifiedModule<
{
lorem(): Promise<string>
ipsum(): void
},
true
>
type B = {
lorem(): string
}
type Merged = MergeModuleMethods<A, B>
type Expected = {
lorem(): string
ipsum(): Promise<void>
ipsum(formlike: SubmitEvent | FormData | HTMLFormElement): Promise<void>
}

expectTypeOf<Merged>().toMatchTypeOf<Expected>()
})

test("without form handling", () => {
type A = PromisifiedModule<
{
lorem(): Promise<string>
test: {
ipsum(): void
}
},
false
>
type B = {
lorem(): string
}
type Merged = MergeModuleMethods<A, B>
type Expected = {
lorem(): string
test: {
ipsum(): Promise<void>
}
}
expectTypeOf<Merged>().toMatchTypeOf<Expected>()
})
})
25 changes: 25 additions & 0 deletions libs/rpc/src/types/merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { AnyFunction } from "../interfaces"

export type MergeModuleMethods<
ModuleGiven extends object,
Override extends object = never,
Keys extends keyof ModuleGiven = Extract<keyof ModuleGiven, string>,
> = [ModuleGiven] extends [never]
? [Override] extends [never]
? never
: Override
: {
[Key in Keys]: Key extends keyof Override
? ModuleGiven[Key] extends AnyFunction
? Override[Key] extends AnyFunction
? Override[Key]
: ModuleGiven[Key]
: Override[Key] extends object
? ModuleGiven[Key] extends object
? MergeModuleMethods<ModuleGiven[Key], Override[Key]>
: Override[Key]
: Override[Key]
: ModuleGiven[Key]
} & {
[Key in Exclude<keyof Override, Keys>]: Override[Key]
}

0 comments on commit 487cac0

Please sign in to comment.