UNPKG

@benpsnyder/analogjs-esm-trpc

Version:

Angular/Nitro-based tRPC integration

315 lines (306 loc) 11.2 kB
import { InjectionToken, ApplicationRef, APP_BOOTSTRAP_LISTENER, inject, makeStateKey, TransferState, signal } from '@angular/core'; import 'isomorphic-fetch'; import { TRPCClientError, httpBatchLink } from '@trpc/client'; import { observable, share } from '@trpc/server/observable'; import { BehaviorSubject, first, Observable, isObservable, firstValueFrom } from 'rxjs'; import superjson from 'superjson'; import { createFlatProxy, createRecursiveProxy } from '@trpc/server/shared'; const tRPC_CACHE_STATE = new InjectionToken('TRPC_HTTP_TRANSFER_STATE_CACHE_STATE'); const provideTrpcCacheState = () => ({ provide: tRPC_CACHE_STATE, useValue: { isCacheActive: new BehaviorSubject(true) }, }); const provideTrpcCacheStateStatusManager = () => ({ provide: APP_BOOTSTRAP_LISTENER, multi: true, useFactory: () => { const appRef = inject(ApplicationRef); const cacheState = inject(tRPC_CACHE_STATE); return () => appRef.isStable .pipe(first((isStable) => isStable)) .subscribe(() => cacheState.isCacheActive.next(false)); }, deps: [ApplicationRef, tRPC_CACHE_STATE], }); function makeCacheKey(request) { const { type, path, input } = request; const encodedParams = Object.entries(input ?? {}).reduce((prev, [key, value]) => prev + `${key}=${JSON.stringify(value)}`, ''); const key = type + '.' + path + '?' + encodedParams; const hash = generateHash(key); return makeStateKey(hash); } /** * A method that returns a hash representation of a string using a variant of DJB2 hash * algorithm. * * This is the same hashing logic that is used to generate component ids. */ function generateHash(value) { let hash = 0; for (const char of value) { hash = (Math.imul(31, hash) + char.charCodeAt(0)) << 0; } // Force positive number hash. // 2147483647 = equivalent of Integer.MAX_VALUE. hash += 2147483647 + 1; return hash.toString(); } const transferStateLink = () => () => { const { isCacheActive } = inject(tRPC_CACHE_STATE); const transferState = inject(TransferState); const isBrowser = typeof window === 'object'; // here we just got initialized in the app - this happens once per app // useful for storing cache for instance return ({ next, op }) => { const shouldUseCache = (op.type === 'query' && !isBrowser) || // always fetch new values on the server isCacheActive.getValue(); // or when initializing the client app --> same behavior as HttpClient if (!shouldUseCache) { return next(op); } const storeKey = makeCacheKey(op); const storeValue = transferState.get(storeKey, null); if (storeValue && isBrowser) { // on the server we don't care about the value we will always fetch a new one // use superjson to parse our superjson string and retrieve our // data return it instead of calling next trpc link return observable((observer) => observer.next(superjson.parse(storeValue))); } return observable((observer) => { return next(op).subscribe({ next(value) { // store returned value from trpc call stringified with superjson in TransferState transferState.set(storeKey, superjson.stringify(value)); observer.next(value); }, error(err) { transferState.remove(storeKey); observer.error(err); }, complete() { observer.complete(); }, }); }); }; }; function createChain(opts) { return observable((observer) => { function execute(index = 0, op = opts.op) { const next = opts.links[index]; if (!next) { throw new Error('No more links to execute - did you forget to add an ending link?'); } const subscription = next({ op, next(nextOp) { const nextObserver = execute(index + 1, nextOp); return nextObserver; }, }); return subscription; } const obs$ = execute(); return obs$.subscribe(observer); }); } // Removed subscription and using new type const clientCallTypeMap = { query: 'query', mutate: 'mutation', }; // Nothing changed, only using new types function createTRPCRxJSClientProxy(client) { return createFlatProxy((key) => { // eslint-disable-next-line no-prototype-builtins if (client.hasOwnProperty(key)) { return client[key]; } return createRecursiveProxy(({ path, args }) => { const pathCopy = [key, ...path]; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const clientCallType = pathCopy.pop(); const procedureType = clientCallTypeMap[clientCallType]; const fullPath = pathCopy.join('.'); return client[procedureType](fullPath, ...args); }); }); } function createTRPCRxJSProxyClient(opts) { const client = new TRPCClient(opts); const proxy = createTRPCRxJSClientProxy(client); return proxy; } /** * Removed subscription method; * Remove converting trpc observables to promises and therefore also the AbortController * Add converting to rxjs observable */ class TRPCClient { constructor(opts) { this.requestId = 0; const combinedTransformer = (() => { const transformer = opts.transformer; if (!transformer) { return { input: { serialize: (data) => data, deserialize: (data) => data, }, output: { serialize: (data) => data, deserialize: (data) => data, }, }; } if ('input' in transformer) { return opts.transformer; } return { input: transformer, output: transformer, }; })(); this.runtime = { transformer: { serialize: (data) => combinedTransformer.input.serialize(data), deserialize: (data) => combinedTransformer.output.deserialize(data), }, combinedTransformer, }; // Initialize the links this.links = opts.links.map((link) => link(this.runtime)); } $request({ type, input, path, context = {}, }) { const chain$ = createChain({ links: this.links, op: { id: ++this.requestId, type, path, input, context, }, }); return trpcObservableToRxJsObservable(chain$.pipe(share())); } query(path, input, opts) { return this.$request({ type: 'query', path, input: input, context: opts?.context, }); } mutation(path, input, opts) { return this.$request({ type: 'mutation', path, input: input, context: opts?.context, }); } } function trpcObservableToRxJsObservable(observable) { return new Observable((subscriber) => { const sub = observable.subscribe({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore next: (value) => subscriber.next(value.result.data), // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore error: (err) => subscriber.error(TRPCClientError.from(err)), complete: () => subscriber.complete(), }); return () => { sub.unsubscribe(); }; }); } const tRPC_INJECTION_TOKEN = new InjectionToken('@benpsnyder/analogjs-esm-trpc proxy client'); function customFetch(input, init) { if (globalThis.$fetch) { return globalThis.$fetch .raw(input.toString(), init) .catch((e) => { throw e; }) .then((response) => ({ ...response, headers: response.headers, json: () => Promise.resolve(response._data), })); } // dev server trpc for analog & nitro if (typeof window === 'undefined') { const host = process.env['NITRO_HOST'] ?? process.env['ANALOG_HOST'] ?? 'localhost'; const port = process.env['NITRO_PORT'] ?? process.env['ANALOG_PORT'] ?? 4205; const base = `http://${host}:${port}`; if (input instanceof Request) { input = new Request(base, input); } else { input = new URL(input, base); } } return fetch(input, init); } const createTrpcClient = ({ url, options, batchLinkOptions, }) => { const TrpcHeaders = signal({}, ...(ngDevMode ? [{ debugName: "TrpcHeaders" }] : [])); const provideTrpcClient = () => [ provideTrpcCacheState(), provideTrpcCacheStateStatusManager(), { provide: tRPC_INJECTION_TOKEN, useFactory: () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore TODO: figure out why TS is complaining return createTRPCRxJSProxyClient({ transformer: options?.transformer, links: [ ...(options?.links ?? []), transferStateLink(), httpBatchLink({ ...(batchLinkOptions ?? {}), headers() { return TrpcHeaders(); }, fetch: customFetch, url: url ?? '', }), ], }); }, deps: [tRPC_CACHE_STATE, TransferState], }, ]; const TrpcClient = tRPC_INJECTION_TOKEN; return { TrpcClient, provideTrpcClient, TrpcHeaders, /** @deprecated use TrpcClient instead */ tRPCClient: TrpcClient, /** @deprecated use provideTrpcClient instead */ provideTRPCClient: provideTrpcClient, /** @deprecated use TrpcHeaders instead */ tRPCHeaders: TrpcHeaders, }; }; async function waitFor(prom) { if (isObservable(prom)) { prom = firstValueFrom(prom); } if (typeof Zone === 'undefined') { return prom; } const macroTask = Zone.current.scheduleMacroTask(`AnalogContentResolve-${Math.random()}`, () => { }, {}, () => { }); return prom.then((p) => { macroTask.invoke(); return p; }); } /** * Generated bundle index. Do not edit. */ export { createTrpcClient, waitFor }; //# sourceMappingURL=benpsnyder-analogjs-esm-trpc.mjs.map