Skip to content

Commit

Permalink
Beginning separation of user-provded options from client-utilized opt…
Browse files Browse the repository at this point in the history
…ions, optimized MergeMethods type
  • Loading branch information
doseofted committed Apr 1, 2024
1 parent b5d009a commit 267ff06
Show file tree
Hide file tree
Showing 12 changed files with 206 additions and 53 deletions.
8 changes: 4 additions & 4 deletions libs/rpc/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { PromiseResolveStatus } from "./interfaces"
import type { PrimOptions, PrimWebSocketEvents, PrimHttpEvents, PrimHttpQueueItem, BlobRecords } from "./interfaces"
import type { RpcCall, RpcAnswer } from "./types/rpc-structure"
import type { PossibleModule, RpcModule } from "./types/rpc-module"
import { CB_PREFIX, PROMISE_PREFIX } from "./constants"
import { RpcPlaceholder, placeholderName } from "./constants"
import { extractPromisePlaceholders } from "./extract/promises"

export type PrimClient<ModuleType extends PossibleModule> = RpcModule<ModuleType>
Expand Down Expand Up @@ -77,7 +77,7 @@ export function createPrimClient<
if (targetIsCallable) {
// if an argument is a callback reference, the created callback below will send the result back to client
const argsWithListeners = argsProcessed.map(arg => {
const argIsReferenceToCallback = typeof arg === "string" && arg.startsWith(CB_PREFIX)
const argIsReferenceToCallback = typeof arg === "string" && arg.startsWith(RpcPlaceholder.CallbackPrefix)
if (!argIsReferenceToCallback) {
return arg
}
Expand Down Expand Up @@ -120,7 +120,7 @@ export function createPrimClient<
return arg
}
callbacksWereGiven = true
const callbackReferenceIdentifier = [CB_PREFIX, nanoid()].join("")
const callbackReferenceIdentifier = placeholderName(RpcPlaceholder.CallbackPrefix)
const handleRpcCallbackResult = (msg: RpcAnswer) => {
if (msg.id !== callbackReferenceIdentifier) {
return
Expand All @@ -142,7 +142,7 @@ export function createPrimClient<
const result = new Promise<RpcAnswer>((resolve, reject) => {
const promiseEvents = mitt<Record<string | number, unknown>>()
wsEvent.on("response", answer => {
if (answer.id.toString().startsWith(PROMISE_PREFIX)) {
if (answer.id.toString().startsWith(RpcPlaceholder.PromisePrefix)) {
promiseEvents.emit(answer.id, answer.result)
promiseEvents.off(answer.id)
return
Expand Down
2 changes: 1 addition & 1 deletion libs/rpc/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { PrimOptions } from "../interfaces"
import type { RpcModule, PossibleModule } from "../types/rpc-module"
import type { MergeModuleMethods } from "../types/merge"

export function createPrimClient<
export function createRpcClient<
ModuleType extends PossibleModule = never,
GivenOptions extends PrimOptions = PrimOptions,
>(options?: GivenOptions) {
Expand Down
17 changes: 11 additions & 6 deletions libs/rpc/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
/** Callback prefix */
export const CB_PREFIX = "_cb_"
/** Binary prefix (Blob/File) */
export const BLOB_PREFIX = "_bin_"
/** Promise prefix */
export const PROMISE_PREFIX = "_prom_"
import { nanoid } from "nanoid"

export enum RpcPlaceholder {
CallbackPrefix = "_cb_",
BinaryPrefix = "_bin_",
PromisePrefix = "_prom_",
}

export function placeholderName(type: RpcPlaceholder, id?: string) {
return `${type}${id ?? nanoid()}`
}
21 changes: 7 additions & 14 deletions libs/rpc/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
enum PrimErr {
enum RpcErrorCode {
InvalidRpcResult = 0,
}

const errorMessages = {
[PrimErr.InvalidRpcResult]: "Invalid RPC result",
}

export class PrimRpcError extends Error {
public primRpc = true

public constructor(
public code: PrimErr,
messageOverride?: string
export class RpcError extends Error {
constructor(
public code: RpcErrorCode,
message?: string
) {
super(messageOverride || errorMessages[code])
const defaultMessage = "see Prim+RPC documentation for more information"
super(`Error ${code}: ${message ?? defaultMessage}`)
}
}

// const err = new PrimRpcError(PrimErr.InvalidRpcResult)
6 changes: 3 additions & 3 deletions libs/rpc/src/extract/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { nanoid } from "nanoid"
import type { UniqueTypePrefix } from "../interfaces"
import { RpcPlaceholder } from "../constants"

/**
* Extract given type `T` from any given argument (object/array/primitive) to form `Record<string, T>`.
Expand All @@ -16,7 +16,7 @@ import type { UniqueTypePrefix } from "../interfaces"
export function extractGivenData<Extracted = unknown>(
given: unknown,
extractMatches: (given: unknown) => Extracted,
prefix: UniqueTypePrefix
prefix: RpcPlaceholder
): [given: unknown, extracted: Record<string, Exclude<Extracted, false>>] {
const extractedRecord: Record<string, Exclude<Extracted, false>> = {}
const extracted = extractMatches(given)
Expand Down Expand Up @@ -76,7 +76,7 @@ export function extractGivenData<Extracted = unknown>(
export function mergeGivenData<Extract = unknown>(
given: unknown,
extracted: Record<string, Extract>,
prefix: UniqueTypePrefix
prefix: RpcPlaceholder
): unknown {
if (typeof given === "string" && given.startsWith(prefix)) {
return extracted[given] ?? given
Expand Down
6 changes: 3 additions & 3 deletions libs/rpc/src/extract/blobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Copyright 2023 Ted Klingenberg
// SPDX-License-Identifier: Apache-2.0

import { BLOB_PREFIX } from "../constants"
import { RpcPlaceholder } from "../constants"
import { extractGivenData, mergeGivenData } from "./base"

/**
Expand Down Expand Up @@ -92,7 +92,7 @@ export function extractBlobData(
return extractBlobData(newGiven, true)
}
// now we can extract the blobs
const [newlyGiven, blobs] = extractGivenData(given, isBinaryLike, BLOB_PREFIX)
const [newlyGiven, blobs] = extractGivenData(given, isBinaryLike, RpcPlaceholder.BinaryPrefix)
return [newlyGiven, blobs, fromForm]
}

Expand All @@ -107,5 +107,5 @@ export function extractBlobData(
* This undoes `handlePossibleBlobs()`.
*/
export function mergeBlobData(given: unknown, blobs: Record<string, Blob | Buffer>): unknown {
return mergeGivenData(given, blobs, BLOB_PREFIX)
return mergeGivenData(given, blobs, RpcPlaceholder.BinaryPrefix)
}
10 changes: 5 additions & 5 deletions libs/rpc/src/extract/promises.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PROMISE_PREFIX } from "../constants"
import { RpcPlaceholder } from "../constants"
import { featureFlags } from "../flags"
import { extractGivenData, mergeGivenData } from "./base"

Expand All @@ -11,7 +11,7 @@ export function extractPromiseData(
enabled = featureFlags.supportMultiplePromiseResults
): [given: unknown, promises: Record<string, Promise<unknown>>] {
if (!enabled) return [given, {}]
return extractGivenData(given, isPromise, PROMISE_PREFIX)
return extractGivenData(given, isPromise, RpcPlaceholder.PromisePrefix)
}

/* export async function resolveExtractedPromises<T = unknown>(promises: Record<string, Promise<T>>) {
Expand All @@ -29,11 +29,11 @@ export function extractPromiseData(
} */

function mergePromiseData(given: unknown, promises: Record<string, Promise<unknown>>): unknown {
return mergeGivenData(given, promises, PROMISE_PREFIX)
return mergeGivenData(given, promises, RpcPlaceholder.PromisePrefix)
}

function isPromisePlaceholder(given: unknown) {
return typeof given === "string" && given.startsWith(PROMISE_PREFIX) ? given : false
return typeof given === "string" && given.startsWith(RpcPlaceholder.PromisePrefix) ? given : false
}

/** Take Promise placeholders from a server-given result and turn those into real Promises */
Expand All @@ -43,7 +43,7 @@ export function extractPromisePlaceholders(
enabled = featureFlags.supportMultiplePromiseResults
): unknown {
if (!enabled) return given
const [_, extracted] = extractGivenData(given, isPromisePlaceholder, PROMISE_PREFIX)
const [_, extracted] = extractGivenData(given, isPromisePlaceholder, RpcPlaceholder.PromisePrefix)
const extractedTransformed: Record<string, Promise<unknown>> = {}
for (const [replacedKey, originalKey] of Object.entries(extracted)) {
extractedTransformed[replacedKey] = new Promise(resolve => {
Expand Down
12 changes: 0 additions & 12 deletions libs/rpc/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,6 @@ import type { RpcCall, RpcAnswer } from "./types/rpc-structure"
import type { WithoutFunctionWrapper, WithoutPromiseWrapper } from "./types/rpc-module"
import type { RpcModule } from "./types/rpc-module"

// SECTION RPC additional structures
export enum UniquePrefixName {
/** Prefix for binary data */
Binary = "bin",
/** Prefix for callbacks */
Callback = "cb",
/** Prefix for promises */
Promise = "prom",
}
export type UniqueTypePrefix = `_${UniquePrefixName}_${string}`
// !SECTION

// SECTION HTTP/WebSocket events
export enum PromiseResolveStatus {
/** Promise has not been created yet */ Unhandled,
Expand Down
167 changes: 167 additions & 0 deletions libs/rpc/src/options/provided-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import type { PartialDeep, Schema } from "type-fest"
import type {
PossibleModule,
RpcMethodSpecifier,
RpcModule,
WithoutFunctionWrapper,
WithoutPromiseWrapper,
} from "../types/rpc-module"

/**
* An object conforming to `JSON` (with `parse` and `stringify` methods) that implements serialization/deserialization
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type TransformHandler<StringifyType extends string = any> = {
/** Convert JavaScript object into a transport-friendly format */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stringify: (value: any) => StringifyType
/** Convert transport-friendly format into JavaScript object */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parse: (text: StringifyType) => any
/** Optionally, define transport media type */
// eslint-disable-next-line @typescript-eslint/ban-types -- `string & {}` is needed for helpful type hints
mediaType?: "application/json" | "application/octet-stream" | "application/yaml" | "text/xml" | (string & {})
/** Optional flag to determine if media is in binary format */
binary?: boolean
}

export interface UserProvidedClientOptions<
GivenModule extends PossibleModule = PossibleModule,
FormHandling extends boolean = boolean,
> {
/**
* Optionally provide a module to the RPC client. This module will be utilized before sending RPC to the server.
* If a method isn't given on the module, it is sent as RPC to the server. This is useful during SSR when a client
* has access to the module server-side but not client-side. It may also be useful for sharing isomorphic utilities
* between the server and client.
*
* @default null
*/
// The module may be given as a dynamic import or only partially: support various wrappers around the `GivenModule`
module?:
| (() => Promise<PartialDeep<WithoutPromiseWrapper<WithoutFunctionWrapper<GivenModule>>>>)
| Promise<PartialDeep<WithoutPromiseWrapper<WithoutFunctionWrapper<GivenModule>>>>
| PartialDeep<WithoutPromiseWrapper<WithoutFunctionWrapper<GivenModule>>>
| null
/**
* RPC can be sent in batches to the server when this option is set, depending on the transport used to send RPC.
* Time duration, given in milliseconds, determines how long to wait before sending a batch of RPC to the server.
* It is recommended to keep this duration very low (under `100` milliseconds).
*
* @default false
*/
batchTime?: number | false
/**
* Methods to serialize and deserialize JavaScript objects, conforming to the `JSON` interface. By default, the `JSON`
* object is utilized and will convert RPC to/from JSON. Serialization is not limited to JSON: you may use well-known
* formats like MessagePack, YAML, XML, or others.
*
* The default transform uses the `destr` package for serialization (in place of `JSON.parse`) and `JSON.stringify`
* for deserialization.
*
* @default ```ts
* { stringify: JSON.stringify, parse: destr, binary: false, mediaType: "application/json" }
* ```
*/
transformHandler?: TransformHandler
/**
* Module methods cannot be called by default, they must explicitly be marked as RPC.For methods to be considered RPC,
* they must either (a) define a property `.rpc` on itself or (b) have its method name listed in the allow list
* (this option). This allow list follows the schema of the module provided.
*
* Setting a method to `true` will allow client-side access to server-side functions (if over HTTP: using POST
* requests). You may optionally set a method to be `"idempotent"` to reflect that the method may be called multiple
* times without side effects (if over HTTP: using POST requests or, optionally, a GET requests).
*
* @default {}
*/
allowSchema?: PartialDeep<Schema<RpcModule<GivenModule, false, false>, RpcMethodSpecifier>>
/**
* Functions in JavaScript are objects and can have other properties or methods defined on them. By default, these
* methods (on other methods) cannot be called as RPC. You may set a key/value pair of global method names (the keys)
* allowed on any method given in the module as `true` or `"idempotent"` (the value).
*
* Note that some built-in methods are disallowed regardless of this setting (for instance: `call`, `apply`, `bind`).
*/
allowedMethodsOnMethod?: { [methodName: string]: RpcMethodSpecifier }
/**
* A group of error-specific options when handling RPC errors. Set to `true` to enable the default set of rules below
* or set to `false` to disable error handling. More granular options are available by setting to an object.
*
* @default { enabled: true, stackTrace: false }
*/
handleErrors?:
| boolean
| {
/**
* When a module's method throws an error on the server, the RPC client catches that error to transport it to
* the client. When this option is set to `true`, the error will be transformed into a JSON representation and
* reconstructed on the client. When set to `false`, the error will be forwarded to your Transform Handler
* (by default, the `JSON` object which will turn the error into an empty object: `{}`).
*
* If your Transform Handler already supports Error-like objects or you do not wish to send error details to the
* client, you may disable this option.
*
* @default true
*/
enabled: true
/**
* When `error.handling` is `true`, this option determines if the error's stack trace (if any) is included in
* the RPC result. It is recommended that this option is only enabled conditionally during development.
*
* @default false
*/
stackTrace?: boolean
}
| { enabled: false }
/**
* Whether binary types should be extracted from RPC for transport, depending on if the transport supports the
* separated blobs. By default, binary types like `File` and `Blob` are extracted from generated RPC before being
* sent over your chosen transport. This is useful when using `JSON` as a Transform Handler since JSON cannot handle
* binary files but transports like HTTP can do so (for instance, using multipart form data).
*
* If your JSON handler does support binary files or you have no intention of handling binary files, you may disable
* this option.
*
* @default true
*/
handleBlobs?: boolean
/**
* By default, all methods called from the RPC client accept `FormData`, `HTMLFormElement` or `SubmitEvent` object
* types as an argument. Items in HTML forms will be transformed into a regular JavaScript object with keys that that
* match the form input names. This allows the client to easily be used with forms without manual transformations.
*
* If you'd like to transform FormData manually using a custom Transform Handler or skip handling of HTML forms,
* you may disable this option.
*
* @default true
*/
handleForms?: FormHandling
/**
* Transform given arguments prior to sending RPC to server. This hook may alternatively intercept a function call and
* return a result early. This hook is expected to be synchronous and return an object with either `.args` or
* `.result`, otherwise this function should return void to continue with the original arguments.
*
* This may be useful for logging, caching, or other pre-processing of RPC calls.
*
* @default undefined
*/
preRequest?: (args: unknown[], name: string) => { args: unknown[] } | { result: unknown } | undefined | void
/**
* Transform given result prior to being returned to the RPC caller. This hook may be asynchronous.
* If a the RPC result is a thrown error, this hook will not be called and the error will be thrown at the call site.
*
* This may be useful for logging, caching, or other post-processing of RPC results.
*
* @default undefined
*/
postRequest?: (args: unknown[], result: unknown, name: string) => unknown
/**
* Transform given error prior to being thrown to the RPC caller. This hook may be asynchronous.
*
* This hook may be useful for invalidating cache or global handling of errors in an app.
*
* @default undefined
*/
postError?: (args: unknown[], error: unknown, name: string) => unknown
}
6 changes: 2 additions & 4 deletions libs/rpc/src/types/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ export type MergeModuleMethods<
: Override
: {
[Key in Keys]: Key extends keyof Override
? ModuleGiven[Key] extends (...args: unknown[]) => unknown
? Override[Key] extends (...args: unknown[]) => unknown
? Override[Key]
: ModuleGiven[Key]
? Override[Key] extends (...args: unknown[]) => unknown
? Override[Key]
: Override[Key] extends object
? ModuleGiven[Key] extends object
? MergeModuleMethods<ModuleGiven[Key], Override[Key]>
Expand Down
2 changes: 2 additions & 0 deletions libs/rpc/src/types/rpc-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type FunctionWithFormParameter<Params extends unknown[], Result, F extends true
(formLike: SubmitEvent | FormData | HTMLFormElement): Result
}

export type RpcMethodSpecifier = true | "idempotent"

export type RpcModule<
ModuleGiven extends PossibleModule,
HandleForm extends true | false = true,
Expand Down
2 changes: 1 addition & 1 deletion prim-rpc.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"statusBar.foreground": "#15202b",
"statusBarItem.hoverBackground": "#3999b5",
"statusBarItem.remoteBackground": "#57b0ca",
"statusBarItem.remoteForeground": "#15202b",
"statusBarItem.remoteForeground": "#15202b"
},
"typescript.tsdk": "🌳 Prim+RPC/node_modules/typescript/lib",
},
Expand Down

0 comments on commit 267ff06

Please sign in to comment.