Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(uploads): Configure apollo client to do multi-part form uploads #11175

Merged
merged 16 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changesets/11175.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
- feat(uploads): Configure apollo client to do multi-part form uploads (#11175) by @dac09

a) Configures the Apollo client we export to use upload link - https://github.com/jaydenseric/apollo-upload-client
b) Configures our API side fastify server to accept multipart form data

Notes:

1. apollo-upload-client is ESM only. In order to get this working for prerender, I had to bundle it for CJS version only.
Without this change you get errors during prerender like this:

```
Error [ERR_REQUIRE_ESM]: require() of ES Module /Users/dac09/Experiments/apollo-upload-link/node_modules/apollo-upload-client/createUploadLink.mjs not supported.
```

2. Currently the multi-part config only applies when you have a server file (see separate PR with fix: https://github.com/redwoodjs/redwood/pull/11176)

3. The upload link internally will handle whether to do a regular POST or multipart POST. In order to make use of this on the backend you need to set your graphql schema field to a scalar of `File`
1 change: 1 addition & 0 deletions packages/api-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"test:watch": "vitest watch"
},
"dependencies": {
"@fastify/multipart": "8.3.0",
"@fastify/url-data": "5.4.0",
"@redwoodjs/context": "workspace:*",
"@redwoodjs/fastify-web": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions packages/api-server/src/__tests__/createServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ describe('resolveOptions', () => {
requestTimeout:
DEFAULT_CREATE_SERVER_OPTIONS.fastifyServerOptions.requestTimeout,
logger: DEFAULT_CREATE_SERVER_OPTIONS.logger,
bodyLimit: DEFAULT_CREATE_SERVER_OPTIONS.fastifyServerOptions.bodyLimit,
},
apiPort: 65501,
apiHost: '::',
Expand Down
59 changes: 59 additions & 0 deletions packages/api-server/src/__tests__/graphqlPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import path from 'path'

import fastifyMultipart from '@fastify/multipart'
import {
vi,
beforeAll,
afterAll,
describe,
afterEach,
it,
expect,
} from 'vitest'

import createFastifyInstance from '../fastify'
import { redwoodFastifyGraphQLServer } from '../plugins/graphql'

// Set up RWJS_CWD.
let original_RWJS_CWD: string | undefined

beforeAll(async () => {
original_RWJS_CWD = process.env.RWJS_CWD
process.env.RWJS_CWD = path.join(__dirname, './fixtures/redwood-app')
})

afterAll(() => {
process.env.RWJS_CWD = original_RWJS_CWD
})

describe('RedwoodFastifyGraphqlServer Fastify Plugin', () => {
beforeAll(async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
})

afterEach(() => {
vi.clearAllMocks()
})

afterAll(async () => {
vi.mocked(console.log).mockRestore()
vi.mocked(console.warn).mockRestore()
})

it('registers the fastify multipart plugin to support graphql-uploads', async () => {
const fastifyInstance = await createFastifyInstance()

const registerSpy = vi.spyOn(fastifyInstance, 'register')

// Although this is not how you normally register a plugin, we're going to
// doing it this way gives us the ability to spy on the register method
await redwoodFastifyGraphQLServer(fastifyInstance, {
redwood: {},
})

expect(registerSpy).toHaveBeenCalledWith(fastifyMultipart)

await fastifyInstance.close()
})
})
4 changes: 3 additions & 1 deletion packages/api-server/src/createServerHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export interface CreateServerOptions {

type DefaultCreateServerOptions = Required<
Omit<CreateServerOptions, 'fastifyServerOptions'> & {
fastifyServerOptions: Pick<FastifyServerOptions, 'requestTimeout'>
fastifyServerOptions: FastifyServerOptions
dac09 marked this conversation as resolved.
Show resolved Hide resolved
}
>

Expand All @@ -59,6 +59,7 @@ export const DEFAULT_CREATE_SERVER_OPTIONS: DefaultCreateServerOptions = {
},
fastifyServerOptions: {
requestTimeout: 15_000,
bodyLimit: 1024 * 1024 * 15, // 15MB
},
configureApiServer: () => {},
parseArgs: true,
Expand Down Expand Up @@ -89,6 +90,7 @@ export function resolveOptions(
requestTimeout:
DEFAULT_CREATE_SERVER_OPTIONS.fastifyServerOptions.requestTimeout,
logger: options.logger ?? DEFAULT_CREATE_SERVER_OPTIONS.logger,
bodyLimit: DEFAULT_CREATE_SERVER_OPTIONS.fastifyServerOptions.bodyLimit,
},
configureApiServer:
options.configureApiServer ??
Expand Down
8 changes: 4 additions & 4 deletions packages/api-server/src/plugins/graphql.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fastifyMultiPart from '@fastify/multipart'
import fastifyUrlData from '@fastify/url-data'
import fg from 'fast-glob'
import type {
Expand All @@ -6,7 +7,6 @@ import type {
FastifyReply,
FastifyRequest,
} from 'fastify'
import fastifyRawBody from 'fastify-raw-body'

import type { GlobalContext } from '@redwoodjs/context'
import { getAsyncStoreInstance } from '@redwoodjs/context/dist/store'
Expand All @@ -33,9 +33,9 @@ export async function redwoodFastifyGraphQLServer(
redwoodOptions.apiRootPath = coerceRootPath(redwoodOptions.apiRootPath)

fastify.register(fastifyUrlData)
// Starting in Fastify v4, we have to await the fastifyRawBody plugin's registration
// to ensure it's ready
await fastify.register(fastifyRawBody)
// We register the multiPart plugin, but not the raw body plugin.
// This is to allow multi-part form data to be parsed - otherwise you get errors
fastify.register(fastifyMultiPart)

const method = ['GET', 'POST', 'OPTIONS'] as HTTPMethods[]

Expand Down
1 change: 1 addition & 0 deletions packages/internal/src/generate/graphqlCodeGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ async function getPluginConfig(side: CodegenSide) {
JSONObject: 'Prisma.JsonObject',
Time: side === CodegenSide.WEB ? 'string' : 'Date | string',
Byte: 'Buffer',
File: 'File',
},
// prevent type names being PetQueryQuery, RW generators already append
// Query/Mutation/etc
Expand Down
21 changes: 20 additions & 1 deletion packages/web/build.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { writeFileSync } from 'node:fs'

import * as esbuild from 'esbuild'

import {
build,
defaultBuildOptions,
Expand All @@ -17,7 +19,11 @@ import {
*/
await build({
entryPointOptions: {
ignore: [...defaultIgnorePatterns, 'src/__typetests__/**'], //, 'src/entry/**'],
ignore: [
...defaultIgnorePatterns,
'src/__typetests__/**',
'src/bundled/**', // <-- ⭐
],
},
buildOptions: {
...defaultBuildOptions,
Expand All @@ -42,6 +48,19 @@ await build({
},
})

// Workaround for apollo-client-upload being ESM-only
// In ESM version of rwjs/web, we don't actually bundle it, we just reexport.
// In the CJS version (see ⭐ above), we bundle it below.
// This only ever gets used during prerender, so bundle size is not a concern.
await esbuild.build({
entryPoints: ['src/bundled/*'],
outdir: 'dist/cjs/bundled',
format: 'cjs',
bundle: true,
logLevel: 'info',
tsconfig: 'tsconfig.build.json',
})

// Place a package.json file with `type: commonjs` in the dist/cjs folder so
// that all .js files are treated as CommonJS files.
writeFileSync('dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }))
Expand Down
2 changes: 2 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
"@redwoodjs/auth": "workspace:*",
"@redwoodjs/server-store": "workspace:*",
"@whatwg-node/fetch": "0.9.20",
"apollo-upload-client": "18.0.1",
"core-js": "3.38.0",
"graphql": "16.9.0",
"graphql-sse": "2.5.3",
Expand All @@ -167,6 +168,7 @@
"@rollup/plugin-babel": "6.0.4",
"@testing-library/jest-dom": "6.4.8",
"@testing-library/react": "14.3.1",
"@types/apollo-upload-client": "^18",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"concurrently": "8.2.2",
Expand Down
29 changes: 16 additions & 13 deletions packages/web/src/apollo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import type {
import {
ApolloProvider,
ApolloClient,
ApolloLink,
InMemoryCache,
split,
ApolloLink,
} from '@apollo/client'
import { setLogVerbosity as apolloSetLogVerbosity } from '@apollo/client/core/core.cjs'
import { setContext } from '@apollo/client/link/context/context.cjs'
import { HttpLink } from '@apollo/client/link/http/http.cjs'
import type { HttpLink } from '@apollo/client/link/http/http.cjs'
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries/persisted-queries.cjs'
import {
useQuery,
Expand All @@ -28,14 +28,14 @@ import {
useSuspenseQuery,
} from '@apollo/client/react/hooks/hooks.cjs'
import { getMainDefinition } from '@apollo/client/utilities/utilities.cjs'
import { fetch as crossFetch } from '@whatwg-node/fetch'
import { Kind, OperationTypeNode } from 'graphql'
import { print } from 'graphql/language/printer.js'

import type { UseAuth } from '@redwoodjs/auth'
import { useNoAuth } from '@redwoodjs/auth'
import './typeOverride.js'

import './typeOverride.js'
import { createUploadLink } from '../bundled/apollo-upload-client.js'
import {
FetchConfigProvider,
useFetchConfig,
Expand Down Expand Up @@ -224,14 +224,17 @@ const ApolloProviderWithFetchConfig: React.FunctionComponent<{

// A terminating link. Apollo Client uses this to send GraphQL operations to a server over HTTP.
// See https://www.apollographql.com/docs/react/api/link/introduction/#the-terminating-link.
let httpLink = new HttpLink({ uri, ...httpLinkConfig })
if (globalThis.RWJS_EXP_STREAMING_SSR) {
httpLink = new HttpLink({ uri, fetch: crossFetch, ...httpLinkConfig })
}
// Internally uploadLink determines whether to use form-data vs http link
const uploadLink: ApolloLink = createUploadLink({
uri,
...httpLinkConfig,
// The upload link types don't match the ApolloLink types, even though it comes from Apollo
// because they use ESM imports and we're using the default ones.
}) as unknown as ApolloLink

// Our terminating link needs to be smart enough to handle subscriptions, and if the GraphQL query
// is subscription it needs to use the SSELink (server sent events link).
const httpOrSSELink =
const uploadOrSSELink =
typeof SSELink !== 'undefined'
? split(
({ query }) => {
Expand All @@ -248,9 +251,9 @@ const ApolloProviderWithFetchConfig: React.FunctionComponent<{
httpLinkConfig,
headers,
}),
httpLink,
uploadLink,
)
: httpLink
: uploadLink

/**
* Use Trusted Documents aka Persisted Operations aka Queries
Expand All @@ -275,8 +278,8 @@ const ApolloProviderWithFetchConfig: React.FunctionComponent<{
},
createPersistedQueryLink({
generateHash: (document: any) => document['__meta__']['hash'],
}).concat(httpOrSSELink),
httpOrSSELink,
}).concat(uploadOrSSELink),
uploadOrSSELink,
)

// The order here is important. The last link *must* be a terminating link like HttpLink, SSELink, or the PersistedQueryLink.
Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/bundled/apollo-upload-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import createUploadLink from 'apollo-upload-client/createUploadLink.mjs'

export { createUploadLink }
Loading