// @effect-diagnostics anyUnknownInErrorContext:off asyncFunction:off newPromise:off strictEffectProvide:off import { BFF_OPERATION_CONTEXT_DETAIL_HEADER, BFF_OPERATION_CONTEXT_HEADER, createRequestContextSnapshot, type OperationContext, type RequestContextInput, } from '@modern-js/runtime-extensions/request-context'; import * as Data from 'effect/Data'; import * as Effect from 'effect/Effect'; import * as Exit from 'effect/Exit'; import { FetchHttpClient, HttpClient, HttpClientRequest } from 'effect/http'; import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, } from 'effect/http-api'; import * as Layer from 'effect/Layer'; import * as ManagedRuntime from 'effect/ManagedRuntime'; import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcSchema, RpcSerialization, } from 'effect/rpc'; import * as Scope from 'effect/Scope'; import { getRpcSerializationLayer } from '../effect/rpcSerialization'; import { type EffectCrossProjectClientOptions, withCrossProjectPolicy, } from './cross-project'; export * as Effect from 'effect/Effect'; export * as HttpClientError from 'effect/http/HttpClientError'; export * as Layer from 'effect/Layer'; export * as Schema from 'effect/Schema'; export type { EffectCrossProjectClientOptions } from './cross-project'; export { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, Rpc, RpcClient, RpcGroup, RpcSchema, RpcSerialization, }; export type EffectHttpApiClientOptions = { crossProject?: EffectCrossProjectClientOptions; baseUrl?: URL | string; /** Headers for every request. A header the request already sets wins; undefined values are skipped. */ headers?: Readonly>; /** * Locale, trace and operation context for every request. An * `operationContext` is sent as `x-operation-id` plus the * `x-modernjs-bff-operation-context` detail the Effect BFF server reads. */ requestContext?: RequestContextInput; transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient; transformResponse?: | (( effect: Effect.Effect, ) => Effect.Effect) | undefined; }; type Nullish = null | undefined; type NonNullish = Exclude; type PreserveNullish = Extract extends never ? Next : Next | Extract; type EffectViewObjectSelection = NonNullish extends Record ? EffectViewSelection> : never; type EffectViewSelectionValue = NonNullish extends readonly (infer Item)[] ? true | EffectViewObjectSelection : NonNullish extends Record ? true | EffectViewSelection> : true; type EffectMaskedValue = Selection extends true ? T : NonNullish extends readonly (infer Item)[] ? PreserveNullish>> : NonNullish extends Record ? PreserveNullish, Selection>> : T; export type EffectViewSelection = { [K in keyof T]?: EffectViewSelectionValue; }; export type EffectViewData = Selection extends true ? T : Selection extends Record ? { [K in keyof Selection & keyof T]: EffectMaskedValue; } : T; export type EffectRpcSerialization = | 'json' | 'ndjson' | 'jsonRpc' | 'ndJsonRpc'; export class EffectRpcClientError extends Data.TaggedError( 'EffectRpcClientError', )<{ readonly cause: unknown; }> {} type EffectRpcMiddlewareLayerOption = [ Rpc.MiddlewareClient, ] extends [never] ? { middlewareLayer?: Layer.Layer, never, never>; } : { middlewareLayer: Layer.Layer, unknown, never>; }; export type EffectRpcClientOptions< Rpcs extends Rpc.Any, Flatten extends boolean = false, > = EffectRpcMiddlewareLayerOption & { url: string; flatten?: Flatten; serialization?: EffectRpcSerialization; }; export type EffectRpcClientHandle< Rpcs extends Rpc.Any, Flatten extends boolean = false, > = (Flatten extends true ? RpcClient.RpcClient.Flat : RpcClient.RpcClient) & { dispose: () => Promise; }; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function applySelection(value: unknown, selection: unknown): unknown { if (selection === true || selection === undefined) { return value; } if (Array.isArray(value)) { return value.map(item => applySelection(item, selection)); } if (!isRecord(value) || !isRecord(selection)) { return value; } const masked: Record = {}; for (const key of Object.keys(selection)) { const selected = selection[key]; if ( selected === undefined || !(key in value) || (selected !== true && !isRecord(selected)) ) { continue; } masked[key] = applySelection(value[key], selected); } return masked; } export function view() { return >( selection: Selection, ) => selection; } export function mask>( value: T, selection: Selection, ): EffectViewData { return applySelection(value, selection) as EffectViewData; } export function runEffectView< T, const Selection extends EffectViewSelection, >( request: PromiseLike, selection: Selection, ): Promise> { return new Promise((resolve, reject) => { request.then( value => resolve(mask(value, selection)), reason => reject(reason), ); }); } const operationContextStringFields = [ 'requestId', 'operationId', 'routePath', 'method', 'schemaHash', 'traceparent', 'traceId', 'spanId', ] as const; /** The operation headers the server reads; scope, claims and attributes stay local. */ function operationContextHeaders( context: OperationContext | undefined, ): Record { if (context === undefined) { return {}; } const detail: Record = {}; for (const field of operationContextStringFields) { const value = context[field]; if (typeof value === 'string' && value.length > 0) { detail[field] = value; } } if (typeof context.operationVersion === 'number') { detail.operationVersion = context.operationVersion; } if (Object.keys(detail).length === 0) { return {}; } return { ...(typeof detail.operationId === 'string' ? { [BFF_OPERATION_CONTEXT_HEADER]: detail.operationId } : {}), [BFF_OPERATION_CONTEXT_DETAIL_HEADER]: JSON.stringify(detail), }; } export function makeEffectHttpApiClient< ApiId extends string, Groups extends HttpApiGroup.Constraint, >( api: HttpApi.HttpApi, options?: EffectHttpApiClientOptions, ): Effect.Effect< HttpApiClient.Client, never, HttpApiGroup.MiddlewareClient > { const requestContext = createRequestContextSnapshot(options?.requestContext); // Request-context headers are more specific than the generic headers. const defaultHeaders: Record = { ...Object.fromEntries( Object.entries(options?.headers ?? {}).flatMap(([header, value]) => value === undefined ? [] : [[header.toLowerCase(), value]], ), ), ...requestContext.headers, ...operationContextHeaders(requestContext.operationContext), }; const transformClient = (client: HttpClient.HttpClient) => { const contextClient = Object.keys(defaultHeaders).length === 0 ? client : client.pipe( HttpClient.mapRequest(request => { let nextRequest = request; for (const [header, value] of Object.entries(defaultHeaders)) { if (nextRequest.headers[header.toLowerCase()] === undefined) { nextRequest = HttpClientRequest.setHeader( nextRequest, header, value, ); } } return nextRequest; }), ); const policyClient = options?.crossProject !== undefined ? withCrossProjectPolicy(api, options.crossProject, contextClient) : contextClient; return typeof options?.transformClient === 'function' ? options.transformClient(policyClient) : policyClient; }; return HttpApiClient.make(api, { baseUrl: options?.baseUrl, transformClient, transformResponse: options?.transformResponse, }).pipe(Effect.provide(FetchHttpClient.layer)); } export function makeEffectRpcClient< Rpcs extends Rpc.Any, const Flatten extends boolean = false, >( group: RpcGroup.RpcGroup, options: EffectRpcClientOptions, ) { const protocolLayer = Layer.provide( RpcClient.layerProtocolHttp({ url: options.url, }), Layer.mergeAll( getRpcSerializationLayer(options.serialization), FetchHttpClient.layer, ), ); const middlewareLayer = options.middlewareLayer ?? Layer.empty; const runtimeLayer = Layer.mergeAll(protocolLayer, middlewareLayer); return Effect.tryPromise({ try: async () => { const runtime = ManagedRuntime.make(runtimeLayer); let scope: Scope.Closeable | undefined; try { const clientScope = await runtime.runPromise(Scope.make()); scope = clientScope; const client = await runtime.runPromise( RpcClient.make(group, { flatten: options.flatten, }).pipe(Effect.provideService(Scope.Scope, clientScope)), ); let disposePromise: Promise | undefined; const clientWithDispose: EffectRpcClientHandle = Object.assign(client, { dispose: () => { disposePromise ??= (async () => { let scopeCloseError: unknown; try { await runtime.runPromise(Scope.close(clientScope, Exit.void)); } catch (error) { scopeCloseError = error; } try { await runtime.dispose(); } catch (error) { if (scopeCloseError === undefined) { throw error; } } if (scopeCloseError !== undefined) { throw scopeCloseError; } })(); return disposePromise; }, }); return clientWithDispose; } catch (error) { if (scope !== undefined) { try { await runtime.runPromise(Scope.close(scope, Exit.void)); } catch { // ignore scope close errors and preserve the original construction error } } try { await runtime.dispose(); } catch { // ignore disposal errors and preserve the original construction error } throw error; } }, catch: (error: unknown) => new EffectRpcClientError({ cause: error }), }); } export const runEffectRequest = Effect.runPromise;