Skip to content

Commit 6980e8f

Browse files
authored
Merge pull request #3292 from SeedCompany/fastify
2 parents 8ae4410 + 531d326 commit 6980e8f

File tree

13 files changed

+910
-536
lines changed

13 files changed

+910
-536
lines changed

.eslintrc.cjs

Lines changed: 5 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[]} */
@@ -88,6 +83,11 @@ const restrictedImports = [
8883
path: '@nestjs/common',
8984
replacement: { importName: 'HttpMiddleware', path: '~/core/http' },
9085
},
86+
{
87+
importNames: ['RouteConfig', 'RouteConstraints'],
88+
path: '@nestjs/platform-fastify',
89+
replacement: { path: '~/core/http' },
90+
},
9191
];
9292

9393
const namingConvention = [

package.json

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,21 @@
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/compress": "^7.0.3",
41+
"@fastify/cookie": "^9.4.0",
42+
"@fastify/cors": "^9.0.1",
3943
"@ffprobe-installer/ffprobe": "^2.1.2",
4044
"@golevelup/nestjs-discovery": "^4.0.0",
4145
"@leeoniya/ufuzzy": "^1.0.11",
4246
"@nestjs/apollo": "^12.0.9",
4347
"@nestjs/common": "^10.2.7",
4448
"@nestjs/core": "^10.2.7",
4549
"@nestjs/graphql": "^12.0.9",
46-
"@nestjs/platform-express": "^10.2.7",
50+
"@nestjs/platform-fastify": "^10.4.3",
4751
"@patarapolw/prettyprint": "^1.0.3",
4852
"@seedcompany/cache": "^2.0.0",
4953
"@seedcompany/common": ">=0.13.1 <1",
@@ -59,16 +63,16 @@
5963
"cli-table3": "^0.6.3",
6064
"clipanion": "^4.0.0-rc.3",
6165
"common-tags": "^1.8.2",
62-
"cookie-parser": "^1.4.6",
6366
"cypher-query-builder": "patch:cypher-query-builder@npm%3A6.0.4#~/.yarn/patches/cypher-query-builder-npm-6.0.4-e8707a5e8e.patch",
6467
"dotenv": "^16.3.1",
6568
"dotenv-expand": "^10.0.0",
6669
"edgedb": "^1.6.0-canary.20240827T111834",
6770
"execa": "^8.0.1",
68-
"express": "^4.18.2",
6971
"extensionless": "^1.7.0",
7072
"fast-safe-stringify": "^2.1.1",
7173
"fastest-levenshtein": "^1.0.16",
74+
"fastify": "^4.28.1",
75+
"fastify-raw-body": "^4.3.0",
7276
"file-type": "^18.6.0",
7377
"glob": "^10.3.10",
7478
"got": "^14.3.0",
@@ -116,9 +120,6 @@
116120
"@seedcompany/eslint-plugin": "^3.4.1",
117121
"@tsconfig/strictest": "^2.0.2",
118122
"@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",
122123
"@types/ffprobe": "^1.1.7",
123124
"@types/graphql-upload": "^16.0.4",
124125
"@types/jest": "^29.5.7",
@@ -154,9 +155,13 @@
154155
"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",
155156
"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",
156157
"@apollo/server-plugin-landing-page-graphql-playground": "npm:empty-npm-package@*",
158+
"@apollo/server/express": "npm:empty-npm-package@*",
157159
"@nestjs/cli/fork-ts-checker-webpack-plugin": "npm:empty-npm-package@*",
158160
"@nestjs/cli/webpack": "npm:empty-npm-package@*",
159161
"@nestjs/cli/typescript": "^5.1.6",
162+
"@types/express": "npm:@types/stack-trace@*",
163+
"@types/express-serve-static-core": "npm:@types/stack-trace@*",
164+
"@types/koa": "npm:@types/stack-trace@*",
160165
"subscriptions-transport-ws": "npm:empty-npm-package@*"
161166
},
162167
"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/components/file/local-bucket.controller.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
Body,
23
Controller,
34
Get,
45
Headers,
@@ -9,9 +10,8 @@ import {
910
} from '@nestjs/common';
1011
import { DateTime } from 'luxon';
1112
import { URL } from 'node:url';
12-
import rawBody from 'raw-body';
1313
import { InputException } from '~/common';
14-
import { HttpAdapter, IRequest, IResponse } from '~/core/http';
14+
import { HttpAdapter, IRequest, IResponse, RawBody } from '~/core/http';
1515
import { FileBucket, InvalidSignedUrlException } from './bucket';
1616

1717
/**
@@ -27,14 +27,13 @@ export class LocalBucketController {
2727
) {}
2828

2929
@Put()
30+
@RawBody({ passthrough: true })
3031
async upload(
3132
@Headers('content-type') contentType: string,
3233
@Request() req: IRequest,
34+
@Body() contents: Buffer,
3335
) {
34-
// Chokes on json files because they are parsed with body-parser.
35-
// Need to disable it for this path or create a workaround.
36-
const contents = await rawBody(req);
37-
if (!contents) {
36+
if (!contents || !Buffer.isBuffer(contents)) {
3837
throw new InputException();
3938
}
4039

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/decorators.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {
2+
FASTIFY_ROUTE_CONFIG_METADATA,
3+
FASTIFY_ROUTE_CONSTRAINTS_METADATA,
4+
} from '@nestjs/platform-fastify/constants.js';
5+
import { Many } from '@seedcompany/common';
6+
import { createMetadataDecorator } from '@seedcompany/nest';
7+
import { FastifyContextConfig } from 'fastify';
8+
import type { RouteConstraint } from 'fastify/types/route';
9+
10+
export const RouteConstraints = createMetadataDecorator({
11+
key: FASTIFY_ROUTE_CONSTRAINTS_METADATA,
12+
types: ['class', 'method'],
13+
setter: (config: RouteConstraint) => config,
14+
});
15+
16+
export const RouteConfig = createMetadataDecorator({
17+
key: FASTIFY_ROUTE_CONFIG_METADATA,
18+
types: ['class', 'method'],
19+
setter: (config: FastifyContextConfig) => config,
20+
});
21+
22+
/**
23+
* @example
24+
* ```ts
25+
* @RawBody()
26+
* route(
27+
* @Request('rawBody') raw: string,
28+
* @Body() contents: JSON
29+
* ) {}
30+
* ```
31+
* @example
32+
* ```ts
33+
* @RawBody({ passthrough: true })
34+
* route(
35+
* @Body() contents: Buffer
36+
* ) {}
37+
* ```
38+
*/
39+
export const RawBody = createMetadataDecorator({
40+
types: ['class', 'method'],
41+
setter: (
42+
config: {
43+
/**
44+
* Pass the raw body through to the handler or
45+
* just to keep the raw body in addition to regular content parsing.
46+
*/
47+
passthrough?: boolean;
48+
/**
49+
* The allowed content types.
50+
* Only applicable if passthrough is true.
51+
* Defaults to '*'
52+
*/
53+
allowContentTypes?: Many<string> | RegExp;
54+
} = {},
55+
) => config,
56+
});

0 commit comments

Comments
 (0)