Skip to content

Commit 6587949

Browse files
committed
Express -> Fastify
1 parent 8ae4410 commit 6587949

File tree

10 files changed

+643
-576
lines changed

10 files changed

+643
-576
lines changed

.eslintrc.cjs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,6 @@ const oldRestrictedImports = [
3737
importNames: ['Dictionary', 'SafeDictionary'],
3838
message: 'Use a type with strict keys instead',
3939
},
40-
{
41-
name: 'express-serve-static-core',
42-
importNames: ['Dictionary'],
43-
message: 'Use a type with strict keys instead',
44-
},
4540
];
4641

4742
/** @type {import('@seedcompany/eslint-plugin').ImportRestriction[]} */

package.json

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,20 @@
3333
"dependencies": {
3434
"@apollo/server": "^4.9.5",
3535
"@apollo/subgraph": "^2.5.6",
36+
"@as-integrations/fastify": "^2.1.1",
3637
"@aws-sdk/client-s3": "^3.440.0",
3738
"@aws-sdk/s3-request-presigner": "^3.440.0",
3839
"@faker-js/faker": "^8.2.0",
40+
"@fastify/cookie": "^9.4.0",
41+
"@fastify/cors": "^9.0.1",
3942
"@ffprobe-installer/ffprobe": "^2.1.2",
4043
"@golevelup/nestjs-discovery": "^4.0.0",
4144
"@leeoniya/ufuzzy": "^1.0.11",
4245
"@nestjs/apollo": "^12.0.9",
4346
"@nestjs/common": "^10.2.7",
4447
"@nestjs/core": "^10.2.7",
4548
"@nestjs/graphql": "^12.0.9",
46-
"@nestjs/platform-express": "^10.2.7",
49+
"@nestjs/platform-fastify": "^10.4.3",
4750
"@patarapolw/prettyprint": "^1.0.3",
4851
"@seedcompany/cache": "^2.0.0",
4952
"@seedcompany/common": ">=0.13.1 <1",
@@ -59,16 +62,15 @@
5962
"cli-table3": "^0.6.3",
6063
"clipanion": "^4.0.0-rc.3",
6164
"common-tags": "^1.8.2",
62-
"cookie-parser": "^1.4.6",
6365
"cypher-query-builder": "patch:cypher-query-builder@npm%3A6.0.4#~/.yarn/patches/cypher-query-builder-npm-6.0.4-e8707a5e8e.patch",
6466
"dotenv": "^16.3.1",
6567
"dotenv-expand": "^10.0.0",
6668
"edgedb": "^1.6.0-canary.20240827T111834",
6769
"execa": "^8.0.1",
68-
"express": "^4.18.2",
6970
"extensionless": "^1.7.0",
7071
"fast-safe-stringify": "^2.1.1",
7172
"fastest-levenshtein": "^1.0.16",
73+
"fastify": "^4.28.1",
7274
"file-type": "^18.6.0",
7375
"glob": "^10.3.10",
7476
"got": "^14.3.0",
@@ -116,9 +118,6 @@
116118
"@seedcompany/eslint-plugin": "^3.4.1",
117119
"@tsconfig/strictest": "^2.0.2",
118120
"@types/common-tags": "^1.8.3",
119-
"@types/cookie-parser": "^1.4.5",
120-
"@types/express": "^4.17.20",
121-
"@types/express-serve-static-core": "^4.17.39",
122121
"@types/ffprobe": "^1.1.7",
123122
"@types/graphql-upload": "^16.0.4",
124123
"@types/jest": "^29.5.7",
@@ -154,9 +153,13 @@
154153
"neo4j-driver-bolt-connection@npm:5.20.0": "patch:neo4j-driver-bolt-connection@npm%3A5.20.0#~/.yarn/patches/neo4j-driver-bolt-connection-npm-5.20.0-1f7809f435.patch",
155154
"neo4j-driver-core@npm:5.20.0": "patch:neo4j-driver-core@npm%3A5.20.0#~/.yarn/patches/neo4j-driver-core-npm-5.20.0-99216f6938.patch",
156155
"@apollo/server-plugin-landing-page-graphql-playground": "npm:empty-npm-package@*",
156+
"@apollo/server/express": "npm:empty-npm-package@*",
157157
"@nestjs/cli/fork-ts-checker-webpack-plugin": "npm:empty-npm-package@*",
158158
"@nestjs/cli/webpack": "npm:empty-npm-package@*",
159159
"@nestjs/cli/typescript": "^5.1.6",
160+
"@types/express": "npm:@types/stack-trace@*",
161+
"@types/express-serve-static-core": "npm:@types/stack-trace@*",
162+
"@types/koa": "npm:@types/stack-trace@*",
160163
"subscriptions-transport-ws": "npm:empty-npm-package@*"
161164
},
162165
"dependenciesMeta": {

src/components/authentication/current-user.provider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ export class EdgeDBCurrentUserProvider
4747
const { request, session$ } =
4848
GqlExecutionContext.create(context).getContext();
4949
if (request) {
50-
const optionsHolder = this.optionsHolderByRequest.get(request)!;
50+
const optionsHolder = this.optionsHolderByRequest.get(request.raw)!;
5151
session$.subscribe((session) => {
5252
this.applyToOptions(session, optionsHolder);
5353
});
5454
}
5555
} else if (type === 'http') {
5656
const request = context.switchToHttp().getRequest();
57-
const optionsHolder = this.optionsHolderByRequest.get(request)!;
57+
const optionsHolder = this.optionsHolderByRequest.get(request.raw)!;
5858
this.applyToOptions(request.session, optionsHolder);
5959
}
6060

src/core/exception/exception.filter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class ExceptionFilter implements GqlExceptionFilter {
2626

2727
const hack = isFromHackAttempt(exception, args);
2828
if (hack) {
29-
hack.destroy();
29+
hack.raw.destroy();
3030
return;
3131
}
3232

src/core/graphql/graphql.module.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { ApolloDriver } from '@nestjs/apollo';
22
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
33
import { APP_INTERCEPTOR } from '@nestjs/core';
44
import { GraphQLModule as NestGraphqlModule } from '@nestjs/graphql';
5-
import createUploadMiddleware from 'graphql-upload/graphqlUploadExpress.mjs';
5+
import processUploadRequest, {
6+
UploadOptions,
7+
} from 'graphql-upload/processRequest.mjs';
8+
import { HttpAdapterHost } from '~/core/http';
69
import { TracingModule } from '../tracing';
710
import { GqlContextHost, GqlContextHostImpl } from './gql-context.host';
811
import { GraphqlErrorFormatter } from './graphql-error-formatter';
@@ -13,6 +16,8 @@ import { GraphqlOptions } from './graphql.options';
1316

1417
import './types';
1518

19+
const FileUploadOptions: UploadOptions = {};
20+
1621
@Module({
1722
imports: [TracingModule],
1823
providers: [
@@ -42,15 +47,35 @@ export class GraphqlOptionsModule {}
4247
exports: [NestGraphqlModule, GqlContextHost],
4348
})
4449
export class GraphqlModule implements NestModule {
45-
constructor(private readonly middleware: GqlContextHostImpl) {}
50+
constructor(
51+
private readonly middleware: GqlContextHostImpl,
52+
private readonly app: HttpAdapterHost,
53+
) {}
4654

4755
configure(consumer: MiddlewareConsumer) {
4856
// Always attach our GQL Context middleware.
4957
// It has its own logic to handle non-gql requests.
5058
consumer.apply(this.middleware.use).forRoutes('*');
5159

52-
// Attach the graphql-upload middleware to the graphql endpoint.
53-
const uploadMiddleware = createUploadMiddleware();
54-
consumer.apply(uploadMiddleware).forRoutes('/graphql', '/graphql/*');
60+
// Setup file upload handling
61+
const fastify = this.app.httpAdapter.getInstance();
62+
const multipartRequests = new WeakSet();
63+
fastify.addContentTypeParser(
64+
'multipart/form-data',
65+
(req, payload, done) => {
66+
multipartRequests.add(req);
67+
done(null);
68+
},
69+
);
70+
fastify.addHook('preValidation', async (req, reply) => {
71+
if (!multipartRequests.has(req) || !req.url.startsWith('/graphql')) {
72+
return;
73+
}
74+
req.body = await processUploadRequest(
75+
req.raw,
76+
reply.raw,
77+
FileUploadOptions,
78+
);
79+
});
5580
}
5681
}

src/core/graphql/graphql.options.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { ContextFunction } from '@apollo/server';
2-
import { ExpressContextFunctionArgument } from '@apollo/server/express4';
31
import {
42
ApolloServerPluginLandingPageLocalDefault,
53
ApolloServerPluginLandingPageProductionDefault,
64
} from '@apollo/server/plugin/landingPage/default';
7-
import { ApolloDriverConfig } from '@nestjs/apollo';
5+
import { ApolloFastifyContextFunctionArgument } from '@as-integrations/fastify';
6+
import { ApolloDriverConfig as DriverConfig } from '@nestjs/apollo';
87
import { Injectable } from '@nestjs/common';
98
import { GqlOptionsFactory } from '@nestjs/graphql';
109
import { CacheService } from '@seedcompany/cache';
@@ -29,7 +28,7 @@ export class GraphqlOptions implements GqlOptionsFactory {
2928
private readonly errorFormatter: GraphqlErrorFormatter,
3029
) {}
3130

32-
async createGqlOptions(): Promise<ApolloDriverConfig> {
31+
async createGqlOptions(): Promise<DriverConfig> {
3332
// Apply git hash to Apollo Studio.
3433
// They only look for env, so applying that way.
3534
const version = await this.versionService.version;
@@ -44,6 +43,7 @@ export class GraphqlOptions implements GqlOptionsFactory {
4443
).asRecord;
4544

4645
return {
46+
path: '/graphql/:opName?',
4747
autoSchemaFile: 'schema.graphql',
4848
context: this.context,
4949
playground: false,
@@ -80,14 +80,15 @@ export class GraphqlOptions implements GqlOptionsFactory {
8080
};
8181
}
8282

83-
context: ContextFunction<[ExpressContextFunctionArgument], GqlContextType> =
84-
async ({ req, res }) => ({
85-
[isGqlContext.KEY]: true,
86-
request: req,
87-
response: res,
88-
operation: createFakeStubOperation(),
89-
session$: new BehaviorSubject<Session | undefined>(undefined),
90-
});
83+
context = (
84+
...[request, response]: ApolloFastifyContextFunctionArgument
85+
): GqlContextType => ({
86+
[isGqlContext.KEY]: true,
87+
request,
88+
response,
89+
operation: createFakeStubOperation(),
90+
session$: new BehaviorSubject<Session | undefined>(undefined),
91+
});
9192
}
9293

9394
export const createFakeStubOperation = () => {

src/core/http/http.adapter.ts

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
1+
import cookieParser from '@fastify/cookie';
2+
import cors from '@fastify/cors';
13
// eslint-disable-next-line @seedcompany/no-restricted-imports
24
import { HttpAdapterHost as HttpAdapterHostImpl } from '@nestjs/core';
35
import {
4-
NestExpressApplication as BaseApplication,
5-
ExpressAdapter,
6-
} from '@nestjs/platform-express';
7-
import cookieParser from 'cookie-parser';
8-
import { ConfigService } from '../config/config.service';
9-
import type { CorsOptions } from './index';
10-
import { CookieOptions, IResponse } from './types';
11-
12-
export type NestHttpApplication = BaseApplication & {
13-
configure: (app: BaseApplication, config: ConfigService) => Promise<void>;
6+
FastifyAdapter,
7+
NestFastifyApplication,
8+
} from '@nestjs/platform-fastify';
9+
import { ConfigService } from '~/core/config/config.service';
10+
import type { CookieOptions, CorsOptions, IResponse } from './types';
11+
12+
export type NestHttpApplication = NestFastifyApplication & {
13+
configure: (
14+
app: NestFastifyApplication,
15+
config: ConfigService,
16+
) => Promise<void>;
1417
};
1518

1619
export class HttpAdapterHost extends HttpAdapterHostImpl<HttpAdapter> {}
1720

18-
export class HttpAdapter extends ExpressAdapter {
19-
async configure(app: BaseApplication, config: ConfigService) {
20-
app.enableCors(config.cors as CorsOptions); // typecast to undo deep readonly
21-
app.use(cookieParser());
21+
export class HttpAdapter extends FastifyAdapter {
22+
async configure(app: NestFastifyApplication, config: ConfigService) {
23+
await app.register(cors, {
24+
// typecast to undo deep readonly
25+
...(config.cors as CorsOptions),
26+
});
27+
await app.register(cookieParser);
2228

2329
app.setGlobalPrefix(config.hostUrl$.value.pathname.slice(1));
2430

@@ -31,6 +37,33 @@ export class HttpAdapter extends ExpressAdapter {
3137
value: string,
3238
options: CookieOptions,
3339
) {
34-
response.cookie(name, value, options);
40+
// Avoid linter wanting us to await sending response.
41+
// This method just returns the response instance for fluent interface.
42+
void response.cookie(name, value, options);
43+
}
44+
45+
// @ts-expect-error we don't need to be compatible with base
46+
reply(
47+
response: IResponse | IResponse['raw'],
48+
body: any,
49+
statusCode?: number,
50+
) {
51+
// Avoid linter wanting us to await sending response.
52+
// This method just returns the response instance for fluent interface.
53+
void super.reply(response, body, statusCode);
54+
}
55+
56+
// @ts-expect-error we don't need to be compatible with base
57+
setHeader(response: IResponse, name: string, value: string) {
58+
// Avoid linter wanting us to await sending response.
59+
// This method just returns the response instance for fluent interface.
60+
void super.setHeader(response, name, value);
61+
}
62+
63+
// @ts-expect-error we don't need to be compatible with base
64+
redirect(response: IResponse, statusCode: number, url: string) {
65+
// Avoid linter wanting us to await sending response.
66+
// This method just returns the response instance for fluent interface.
67+
void super.redirect(response, statusCode, url);
3568
}
3669
}

src/core/http/types.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
/* eslint-disable @typescript-eslint/method-signature-style */
22
// eslint-disable-next-line @seedcompany/no-restricted-imports
33
import type { NestMiddleware } from '@nestjs/common';
4-
import type { Request, Response } from 'express';
4+
import type {
5+
FastifyRequest as Request,
6+
FastifyReply as Response,
7+
} from 'fastify';
58
import type { Session } from '~/common';
69

710
// Exporting with I prefix to avoid ambiguity with web global types
811
export type { Request as IRequest, Response as IResponse };
912

10-
export type HttpMiddleware = NestMiddleware<Request, Response>;
13+
export type HttpMiddleware = NestMiddleware<Request['raw'], Response['raw']>;
1114

12-
export { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
13-
export { CookieOptions } from 'express';
15+
export { FastifyCorsOptions as CorsOptions } from '@fastify/cors';
16+
export { SerializeOptions as CookieOptions } from '@fastify/cookie';
1417

15-
declare module 'express' {
16-
export interface Request {
18+
declare module 'fastify' {
19+
export interface FastifyRequest {
1720
session?: Session;
1821
}
1922
}

src/core/timeout.interceptor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export class TimeoutInterceptor implements NestInterceptor {
2525
return next.handle();
2626
}
2727

28-
const timeout$ = fromEvent(response, 'timeout').pipe(
28+
const timeout$ = fromEvent(response.raw, 'timeout').pipe(
2929
map(() => {
3030
throw new ServiceUnavailableException(
3131
'Unable to fulfill request in a timely manner',

0 commit comments

Comments
 (0)