diff --git a/package.json b/package.json index 6960f3e..b6444e4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@angular/ssr": "^19.0.3", "@trpc/client": "11.0.0-rc.643", "@trpc/server": "11.0.0-rc.643", + "cookie": "^1.0.2", "cors": "^2.8.5", "express": "^4.21.2", "rxjs": "~7.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c72db62..9cde7ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@trpc/server': specifier: 11.0.0-rc.643 version: 11.0.0-rc.643(typescript@5.6.3) + cookie: + specifier: ^1.0.2 + version: 1.0.2 cors: specifier: ^2.8.5 version: 2.8.5 @@ -2530,6 +2533,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + copy-anything@2.0.6: resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} @@ -7957,6 +7964,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.0.2: {} + copy-anything@2.0.6: dependencies: is-what: 3.14.1 diff --git a/projects/ngx-trpc/ng-package.json b/projects/ngx-trpc/ng-package.json index 43b48d9..942ea70 100644 --- a/projects/ngx-trpc/ng-package.json +++ b/projects/ngx-trpc/ng-package.json @@ -4,5 +4,5 @@ "lib": { "entryFile": "src/public-api.ts" }, - "allowedNonPeerDependencies": ["superjson", "@trpc/client", "@trpc/server"] + "allowedNonPeerDependencies": ["superjson", "@trpc/client", "@trpc/server", "cookie"] } diff --git a/projects/ngx-trpc/package.json b/projects/ngx-trpc/package.json index 5805020..8afd1e8 100644 --- a/projects/ngx-trpc/package.json +++ b/projects/ngx-trpc/package.json @@ -18,7 +18,8 @@ "tslib": "^2.3.0", "superjson": "^2.2.1", "@trpc/client": "^11.0.0-rc.643", - "@trpc/server": "^11.0.0-rc.643" + "@trpc/server": "^11.0.0-rc.643", + "cookie": "^1.0.2" }, "sideEffects": false, "publishConfig": { diff --git a/projects/ngx-trpc/src/lib/libs/mutex.util.ts b/projects/ngx-trpc/src/lib/libs/mutex.util.ts new file mode 100644 index 0000000..e3b2158 --- /dev/null +++ b/projects/ngx-trpc/src/lib/libs/mutex.util.ts @@ -0,0 +1,42 @@ +export class Mutex { + private queue: (() => void)[] = []; + private locked = false; + + async acquire(): Promise<() => void> { + return new Promise<() => void>((resolve) => { + const release = () => { + this.locked = false; + const next = this.queue.shift(); + if (next) { + next(); + } + }; + + if (!this.locked) { + this.locked = true; + resolve(release); + } else { + this.queue.push(() => { + this.locked = true; + resolve(release); + }); + } + }); + } +} + +export async function wrapInMutex( + fn: () => Promise, + mutex: Mutex, + disable?: boolean +): Promise { + if (disable) { + return await fn(); + } + const release = await mutex.acquire(); + try { + return await fn(); + } finally { + release(); + } +} diff --git a/projects/ngx-trpc/src/lib/trpc.config.ts b/projects/ngx-trpc/src/lib/trpc.config.ts index fa44e37..cc0eaff 100644 --- a/projects/ngx-trpc/src/lib/trpc.config.ts +++ b/projects/ngx-trpc/src/lib/trpc.config.ts @@ -38,5 +38,11 @@ export interface ITrpcConfig { * Default: `['set-cookie']` */ forwardHeaders?: string[]; + + /** + * Disable sequential requests in SSR context. + * Only enable this, if you don't set cookies in createContext while doing sequential requests in ssr. + */ + disableSequentialRequests?: boolean; }; } diff --git a/projects/ngx-trpc/src/lib/trpc.provider.ts b/projects/ngx-trpc/src/lib/trpc.provider.ts index 070316f..c366c92 100644 --- a/projects/ngx-trpc/src/lib/trpc.provider.ts +++ b/projects/ngx-trpc/src/lib/trpc.provider.ts @@ -1,4 +1,11 @@ -import {InjectionToken, PLATFORM_ID, Provider, TransferState} from '@angular/core'; +import { + InjectionToken, + PLATFORM_ID, + Provider, + REQUEST, + RESPONSE_INIT, + TransferState +} from '@angular/core'; import {ITrpcConfig, provideTrpcConfig} from './trpc.config'; import {AnyRouter} from '@trpc/server'; import {CreateTRPCClient, createTRPCRxJSProxyClient} from './rxjs-proxy/create-rxjs-client'; @@ -10,8 +17,8 @@ import { tRPC_CACHE_STATE } from './utils/cache-state'; import {transferStateLink} from './utils/transfer-state-link'; -import {FetchHttpClient} from './utils/fetch-http-client'; import {getPlatformConfig, normalizeWebSocketUrl} from './utils/config-utils'; +import {FetchMiddleware} from './utils/fetch.middleware'; export type TrpcClient = CreateTRPCClient; @@ -29,14 +36,16 @@ export function provideTrpc( provideTrpcCacheStateStatusManager(), { provide: token, - useFactory: (fetchHttpClient: FetchHttpClient, platformId: Object) => { + useFactory: (req: Request | null, res: ResponseInit | null, platformId: Object) => { const _isBrowser = isPlatformBrowser(platformId); const httpConfig = getPlatformConfig(_isBrowser, config.http, config.ssr?.http); + const fetchMiddleware = new FetchMiddleware(config, req, res); + const trpcHttpLink = httpBatchLink({ url: httpConfig.url, - fetch: fetchHttpClient.fetch.bind(fetchHttpClient) + fetch: _isBrowser ? undefined : (input, init) => fetchMiddleware.fetch(input, init) }); let link: TRPCLink = trpcHttpLink; @@ -57,7 +66,7 @@ export function provideTrpc( links: [transferStateLink(), link] }); }, - deps: [FetchHttpClient, PLATFORM_ID, tRPC_CACHE_STATE, TransferState] + deps: [REQUEST, RESPONSE_INIT, PLATFORM_ID, tRPC_CACHE_STATE, TransferState] } ]; } diff --git a/projects/ngx-trpc/src/lib/utils/fetch-http-client.ts b/projects/ngx-trpc/src/lib/utils/fetch-http-client.ts deleted file mode 100644 index 0769fe0..0000000 --- a/projects/ngx-trpc/src/lib/utils/fetch-http-client.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {HttpClient, HttpHeaders} from '@angular/common/http'; -import {inject, Injectable, PLATFORM_ID, REQUEST, RESPONSE_INIT} from '@angular/core'; -import {firstValueFrom} from 'rxjs'; -import {TRPC_CONFIG} from '../trpc.config'; -import {isPlatformBrowser} from '@angular/common'; - -interface FetchImpl { - fetch: typeof fetch; -} - -@Injectable({providedIn: 'root'}) -export class FetchHttpClient implements FetchImpl { - private _http = inject(HttpClient); - private _request = inject(REQUEST, {optional: true}); - private _response = inject(RESPONSE_INIT, {optional: true}); - private _trpcConfig = inject(TRPC_CONFIG); - private _isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - - async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { - if (typeof input != 'string') { - throw new Error('[ngx-trpc] Only string urls are supported right now.'); - } - - const url = input; - const method = init?.method || 'GET'; - let headers = new HttpHeaders(init?.headers as {[header: string]: string}); - - if (this._request) { - this._request.headers.forEach((value: string, key: string) => { - headers = headers.set(key, value); - }); - } - - if (!this._isBrowser && !this._request) { - // Angular is doing a single server-side render after starting the app. Prob a bug in Angular. - // This will prevent an unnecessary createContext call. - return new Response('{}', { - status: 500, - statusText: 'Request is not available on the server.' - }); - } - - const res = await firstValueFrom( - this._http.request(method, url, { - headers, - body: init?.body, - observe: 'response', - responseType: 'blob', - withCredentials: this._trpcConfig.http.withCredentials - }) - ); - const convertedHeaders = this._convertHeaders(res.headers); - - const forwardHeaders = this._trpcConfig.ssr?.forwardHeaders || ['set-cookie']; - if (this._response && this._response.headers instanceof Headers) { - const responseHeaders: Headers = this._response.headers; - - forwardHeaders.forEach((header) => { - const value = convertedHeaders.get(header); - if (value) { - responseHeaders.set(header, value); - } - }); - } - - return new Response(res.body as Blob, { - status: res.status, - statusText: res.statusText, - headers: convertedHeaders - }); - } - - private _convertHeaders(httpHeaders: HttpHeaders): Headers { - const headers = new Headers(); - httpHeaders.keys().forEach((key) => { - headers.append(key, httpHeaders.get(key) as string); - }); - return headers; - } -} diff --git a/projects/ngx-trpc/src/lib/utils/fetch.middleware.ts b/projects/ngx-trpc/src/lib/utils/fetch.middleware.ts new file mode 100644 index 0000000..c2d3222 --- /dev/null +++ b/projects/ngx-trpc/src/lib/utils/fetch.middleware.ts @@ -0,0 +1,67 @@ +import {ITrpcConfig} from '../trpc.config'; +import * as cookie from 'cookie'; +import {Mutex, wrapInMutex} from '../libs/mutex.util'; + +export class FetchMiddleware { + private _setCookiesCache?: string; + + private _mutex = new Mutex(); + + constructor( + private _config: ITrpcConfig, + private _request: Request | null, + private _response: ResponseInit | null + ) {} + + async fetch(input: RequestInfo | URL | string, init?: RequestInit) { + // Wrap this in mutex. Because of setCookieCache, we need to make sequential requests in SSR. + // This should not be a problem, since we use batch calls. + return wrapInMutex( + async () => { + if (typeof input != 'string') { + throw new Error('[ngx-trpc] Only string urls are supported right now.'); + } + + if (this._request) { + const headers: HeadersInit = {}; + this._request.headers.forEach((value: string, key: string) => { + headers[key] = value; + }); + + if (this._setCookiesCache) { + const cookies = cookie.parse(headers['cookie'] || ''); + const newCookies = cookie.parse(this._setCookiesCache); + + for (let key in newCookies) { + cookies[key] = newCookies[key]; + } + + headers['cookie'] = Object.entries(cookies) + .filter(([_, value]) => value !== undefined) + .map(([key, value]) => cookie.serialize(key, value!)) + .join('; '); + } + + init = {...init, headers}; + } + + const r = await fetch(input, init); + if (this._response && this._response.headers && this._response.headers instanceof Headers) { + for (let [key, value] of r.headers) { + if (this._config.ssr?.forwardHeaders?.includes(key) || key === 'set-cookie') { + this._response.headers.set(key, value); + } + + // set the cookie in the angular request object so we can use it in sequential requests when not using batchLink + if (key == 'set-cookie') { + this._setCookiesCache = value; + } + } + } + return r; + }, + this._mutex, + this._config.ssr?.disableSequentialRequests + ); + } +}