diff --git a/src/components/authentication/session.resolver.ts b/src/components/authentication/session.resolver.ts index 1296159985..5367bf2584 100644 --- a/src/components/authentication/session.resolver.ts +++ b/src/components/authentication/session.resolver.ts @@ -13,6 +13,7 @@ import { UnauthenticatedException, } from '~/common'; import { ConfigService, ILogger, Loader, LoaderOf, Logger } from '~/core'; +import { HttpAdapter } from '~/core/http'; import { Privileges } from '../authorization'; import { Power } from '../authorization/dto'; import { UserLoader, UserService } from '../user'; @@ -29,6 +30,7 @@ export class SessionResolver { private readonly config: ConfigService, private readonly sessionInt: SessionInterceptor, private readonly users: UserService, + private readonly http: HttpAdapter, @Logger('session:resolver') private readonly logger: ILogger, ) {} @@ -76,7 +78,7 @@ export class SessionResolver { 'Cannot use cookie session without a response object', ); } - context.response.cookie(name, token, { + this.http.setCookie(context.response, name, token, { ...options, expires: expires ? DateTime.local().plus(expires).toJSDate() diff --git a/src/components/file/file-url.controller.ts b/src/components/file/file-url.controller.ts index 951daf88d7..c72087de2f 100644 --- a/src/components/file/file-url.controller.ts +++ b/src/components/file/file-url.controller.ts @@ -11,7 +11,7 @@ import { } from '@nestjs/common'; import { ID } from '~/common'; import { loggedInSession as verifyLoggedIn } from '~/common/session'; -import { HttpAdapterHost, IRequest, IResponse } from '~/core/http'; +import { HttpAdapter, IRequest, IResponse } from '~/core/http'; import { SessionInterceptor } from '../authentication/session.interceptor'; import { FileService } from './file.service'; @@ -23,7 +23,7 @@ export class FileUrlController { @Inject(forwardRef(() => FileService)) private readonly files: FileService & {}, private readonly sessionHost: SessionInterceptor, - private readonly httpAdapterHost: HttpAdapterHost, + private readonly http: HttpAdapter, ) {} @Get(':fileId/:fileName?') @@ -45,8 +45,7 @@ export class FileUrlController { const url = await this.files.getDownloadUrl(node, download != null); const cacheControl = this.files.determineCacheHeader(node); - const { httpAdapter } = this.httpAdapterHost; - httpAdapter.setHeader(res, 'Cache-Control', cacheControl); - httpAdapter.redirect(res, HttpStatus.FOUND, url); + this.http.setHeader(res, 'Cache-Control', cacheControl); + this.http.redirect(res, HttpStatus.FOUND, url); } } diff --git a/src/components/file/local-bucket.controller.ts b/src/components/file/local-bucket.controller.ts index 632e6d4e03..3b6a942d9e 100644 --- a/src/components/file/local-bucket.controller.ts +++ b/src/components/file/local-bucket.controller.ts @@ -11,7 +11,7 @@ import { DateTime } from 'luxon'; import { URL } from 'node:url'; import rawBody from 'raw-body'; import { InputException } from '~/common'; -import { IRequest, IResponse } from '~/core/http'; +import { HttpAdapter, IRequest, IResponse } from '~/core/http'; import { FileBucket, InvalidSignedUrlException } from './bucket'; /** @@ -21,7 +21,10 @@ import { FileBucket, InvalidSignedUrlException } from './bucket'; export class LocalBucketController { static path = '/local-bucket'; - constructor(private readonly bucket: FileBucket) {} + constructor( + private readonly bucket: FileBucket, + private readonly http: HttpAdapter, + ) {} @Put() async upload( @@ -72,7 +75,7 @@ export class LocalBucketController { 'Content-Disposition': out.ContentDisposition, 'Content-Encoding': out.ContentEncoding, 'Content-Language': out.ContentLanguage, - 'Content-Length': out.ContentLength, + 'Content-Length': String(out.ContentLength), 'Content-Type': out.ContentType, Expires: out.Expires ? DateTime.fromJSDate(out.Expires).toHTTP() @@ -84,7 +87,7 @@ export class LocalBucketController { }; for (const [header, val] of Object.entries(headers)) { if (val != null) { - res.setHeader(header, val); + this.http.setHeader(res, header, val); } } diff --git a/src/core/exception/exception.filter.ts b/src/core/exception/exception.filter.ts index fc18bfda48..c1475ec2c2 100644 --- a/src/core/exception/exception.filter.ts +++ b/src/core/exception/exception.filter.ts @@ -1,7 +1,7 @@ import { ArgumentsHost, Catch, HttpStatus, Injectable } from '@nestjs/common'; import { GqlExceptionFilter } from '@nestjs/graphql'; import { mapValues } from '@seedcompany/common'; -import { HttpAdapterHost } from '~/core/http'; +import { HttpAdapter } from '~/core/http'; import { ConfigService } from '../config/config.service'; import { ILogger, Logger, LogLevel } from '../logger'; import { ValidationException } from '../validation'; @@ -12,7 +12,7 @@ import { isFromHackAttempt } from './is-from-hack-attempt'; @Injectable() export class ExceptionFilter implements GqlExceptionFilter { constructor( - private readonly httpAdapterHost: HttpAdapterHost, + private readonly http: HttpAdapter, @Logger('nest') private readonly logger: ILogger, private readonly config: ConfigService, private readonly normalizer: ExceptionNormalizer, @@ -77,9 +77,8 @@ export class ExceptionFilter implements GqlExceptionFilter { : ex.stack.split('\n'), }; - const { httpAdapter } = this.httpAdapterHost; const res = args.switchToHttp().getResponse(); - httpAdapter.reply(res, out, status); + this.http.reply(res, out, status); } logIt(info: ExceptionJson, error: Error) { diff --git a/src/core/http/http.adapter.ts b/src/core/http/http.adapter.ts index d3ddfcbb97..d07383ae32 100644 --- a/src/core/http/http.adapter.ts +++ b/src/core/http/http.adapter.ts @@ -1,10 +1,36 @@ // eslint-disable-next-line @seedcompany/no-restricted-imports import { HttpAdapterHost as HttpAdapterHostImpl } from '@nestjs/core'; import { - ExpressAdapter as HttpAdapter, - NestExpressApplication as NestHttpApplication, + NestExpressApplication as BaseApplication, + ExpressAdapter, } from '@nestjs/platform-express'; +import cookieParser from 'cookie-parser'; +import { ConfigService } from '../config/config.service'; +import type { CorsOptions } from './index'; +import { CookieOptions, IResponse } from './types'; -export { HttpAdapter, type NestHttpApplication }; +export type NestHttpApplication = BaseApplication & { + configure: (app: BaseApplication, config: ConfigService) => Promise; +}; export class HttpAdapterHost extends HttpAdapterHostImpl {} + +export class HttpAdapter extends ExpressAdapter { + async configure(app: BaseApplication, config: ConfigService) { + app.enableCors(config.cors as CorsOptions); // typecast to undo deep readonly + app.use(cookieParser()); + + app.setGlobalPrefix(config.hostUrl$.value.pathname.slice(1)); + + config.applyTimeouts(app.getHttpServer(), config.httpTimeouts); + } + + setCookie( + response: IResponse, + name: string, + value: string, + options: CookieOptions, + ) { + response.cookie(name, value, options); + } +} diff --git a/src/core/http/http.module.ts b/src/core/http/http.module.ts index 82455435c8..4ccb0e2799 100644 --- a/src/core/http/http.module.ts +++ b/src/core/http/http.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; // eslint-disable-next-line @seedcompany/no-restricted-imports import { HttpAdapterHost as HttpAdapterHostImpl } from '@nestjs/core'; -import { HttpAdapterHost } from './http.adapter'; +import { setOf } from '@seedcompany/common'; +import { getParentTypes } from '~/common'; +import { HttpAdapter, HttpAdapterHost } from './http.adapter'; @Module({ providers: [ @@ -9,7 +11,37 @@ import { HttpAdapterHost } from './http.adapter'; provide: HttpAdapterHost, useExisting: HttpAdapterHostImpl, }, + { + provide: HttpAdapter, + inject: [HttpAdapterHost], + useFactory: async (host: HttpAdapterHost) => { + const availableKeys = setOf( + getParentTypes(HttpAdapter).flatMap((cls) => [ + ...Object.getOwnPropertyNames(cls.prototype), + ...Object.getOwnPropertySymbols(cls.prototype), + ]), + ); + return new Proxy(host, { + get(_, key, receiver) { + const { httpAdapter } = host; + if (key === 'httpAdapter') { + return httpAdapter; + } + if (key === 'constructor') { + return HttpAdapter.constructor; + } + if (!availableKeys.has(key)) { + return undefined; + } + if (!httpAdapter) { + throw new Error('HttpAdapter is not yet available'); + } + return Reflect.get(httpAdapter, key, receiver); + }, + }); + }, + }, ], - exports: [HttpAdapterHost], + exports: [HttpAdapter, HttpAdapterHost], }) export class HttpModule {} diff --git a/src/core/tracing/xray.middleware.ts b/src/core/tracing/xray.middleware.ts index af72e6703d..3c2785851a 100644 --- a/src/core/tracing/xray.middleware.ts +++ b/src/core/tracing/xray.middleware.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; import XRay from 'aws-xray-sdk-core'; -import { HttpMiddleware as NestMiddleware } from '~/core/http'; +import { HttpAdapter, HttpMiddleware as NestMiddleware } from '~/core/http'; import { ConfigService } from '../config/config.service'; import { Sampler } from './sampler'; import { TracingService } from './tracing.service'; @@ -17,6 +17,7 @@ export class XRayMiddleware implements NestMiddleware, NestInterceptor { private readonly tracing: TracingService, private readonly sampler: Sampler, private readonly config: ConfigService, + private readonly http: HttpAdapter, ) {} /** @@ -24,15 +25,14 @@ export class XRayMiddleware implements NestMiddleware, NestInterceptor { */ use: NestMiddleware['use'] = (req, res, next) => { const traceData = XRay.utils.processTraceData( - req.header('x-amzn-trace-id'), + req.headers['x-amzn-trace-id'] as string | undefined, ); const root = new XRay.Segment('cord', traceData.root, traceData.parent); const reqData = new XRay.middleware.IncomingRequestData(req); root.addIncomingRequestData(reqData); // Use public DNS as url instead of specific IP // @ts-expect-error xray library types suck - root.http.request.url = - this.config.hostUrl$.value + req.originalUrl.slice(1); + root.http.request.url = this.config.hostUrl$.value + req.url.slice(1); // Add to segment so interceptor can access without having to calculate again. Object.defineProperty(reqData, 'traceData', { @@ -102,7 +102,8 @@ export class XRayMiddleware implements NestMiddleware, NestInterceptor { : context.switchToHttp().getResponse(); if (res && root instanceof XRay.Segment) { - res.setHeader( + this.http.setHeader( + res, 'x-amzn-trace-id', `Root=${root.trace_id};Sampled=${sampled ? '1' : '0'}`, ); diff --git a/src/main.ts b/src/main.ts index 3de83d8354..37a27a63ff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,6 @@ import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; -import cookieParser from 'cookie-parser'; -import type { CorsOptions, NestHttpApplication } from '~/core/http'; +import type { NestHttpApplication } from '~/core/http'; import './polyfills'; async function bootstrap() { @@ -31,12 +30,7 @@ async function bootstrap() { ); const config = app.get(ConfigService); - app.enableCors(config.cors as CorsOptions); // typecast to undo deep readonly - app.use(cookieParser()); - - app.setGlobalPrefix(config.hostUrl$.value.pathname.slice(1)); - - config.applyTimeouts(app.getHttpServer(), config.httpTimeouts); + await app.configure(app, config); app.enableShutdownHooks(); await app.listen(config.port, () => { diff --git a/test/utility/create-app.ts b/test/utility/create-app.ts index f160a055db..13f1b1c4b4 100644 --- a/test/utility/create-app.ts +++ b/test/utility/create-app.ts @@ -1,8 +1,8 @@ import { faker } from '@faker-js/faker'; -import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { andCall } from '~/common'; -import { HttpAdapter } from '~/core/http'; +import { ConfigService } from '~/core'; +import { HttpAdapter, NestHttpApplication } from '~/core/http'; import { LogLevel } from '~/core/logger'; import { LevelMatcher } from '~/core/logger/level-matcher'; import { AppModule } from '../../src/app.module'; @@ -17,7 +17,7 @@ const origEmail = faker.internet.email.bind(faker.internet); faker.internet.email = (...args) => origEmail(...(args as any)).replace('@', `.${Date.now()}@`); -export interface TestApp extends INestApplication { +export interface TestApp extends NestHttpApplication { graphql: GraphQLTestClient; } @@ -35,6 +35,7 @@ export const createTestApp = async () => { .compile(); const app = moduleFixture.createNestApplication(new HttpAdapter()); + await app.configure(app, app.get(ConfigService)); await app.init(); app.graphql = await createGraphqlClient(app);