@benpsnyder/analogjs-esm-trpc
Version:
Angular/Nitro-based tRPC integration
315 lines (306 loc) • 11.2 kB
JavaScript
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