Skip to content

Middleware and an Upload scalar to add support for GraphQL multipart requests (file uploads via queries and mutations) to various Node.js GraphQL servers.

License

Notifications You must be signed in to change notification settings

meabed/graphql-upload-ts

Repository files navigation

GraphQL Upload for TypeScript

NPM version Build Status Test Coverage Downloads License TypeScript Node

A minimalistic, type-safe middleware for handling GraphQL file uploads in Node.js

Installation β€’ Quick Start β€’ Complete Examples β€’ API β€’ Contributing

✨ Features

  • πŸš€ Full TypeScript Support - Written in TypeScript with complete type definitions
  • πŸ“¦ Framework Agnostic - Works with Express, Koa, Apollo Server, and more
  • πŸ”’ Type-Safe - Strict TypeScript mode enabled with comprehensive type coverage
  • 🎯 Production Ready - Battle-tested with 91%+ test coverage
  • ⚑ High Performance - Efficient file streaming with configurable limits
  • πŸ›‘οΈ Security First - Built-in file validation and sanitization
  • πŸ“ Well Documented - Extensive documentation and real-world examples
  • πŸ”„ Dual Module Support - CommonJS and ESM modules included

πŸ“‹ Table of Contents

πŸ“¦ Installation

npm install graphql-upload-ts graphql
# or
yarn add graphql-upload-ts graphql
# or
pnpm add graphql-upload-ts graphql

Requirements

  • Node.js >= 16
  • GraphQL >= 0.13.1

Build System

This package uses Rollup for bundling and provides CommonJS builds for maximum compatibility. The build configuration has been optimized for simplicity and reliability.

πŸš€ Quick Start

Basic Setup with Express

import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import { graphqlUploadExpress, GraphQLUpload } from 'graphql-upload-ts';
import { GraphQLSchema, GraphQLObjectType, GraphQLString } from 'graphql';

const schema = new GraphQLSchema({
  query: new GraphQLObjectType({
    name: 'Query',
    fields: {
      hello: {
        type: GraphQLString,
        resolve: () => 'Hello World',
      },
    },
  }),
  mutation: new GraphQLObjectType({
    name: 'Mutation',
    fields: {
      uploadFile: {
        type: GraphQLString,
        args: {
          file: { type: GraphQLUpload },
        },
        async resolve(_, { file }) {
          const { filename, createReadStream } = await file;
          const stream = createReadStream();
          // Process your file here
          return `File ${filename} uploaded successfully`;
        },
      },
    },
  }),
});

const app = express();

// Important: graphqlUploadExpress middleware must come BEFORE graphqlHTTP
app.use(
  '/graphql',
  graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 10 }),
  graphqlHTTP({ schema, graphiql: true })
);

app.listen(4000, () => {
  console.log('Server running on http://localhost:4000/graphql');
});

πŸ“š Complete Examples

Manual Schema Construction with GraphQL.js

Click to expand example

When building schemas manually using GraphQL.js (without schema-first approach), you need to use the GraphQLUpload scalar directly:

import {
  GraphQLSchema,
  GraphQLObjectType,
  GraphQLString,
  GraphQLNonNull,
  GraphQLList,
} from 'graphql';
import { GraphQLUpload } from 'graphql-upload-ts';
import fs from 'fs';
import path from 'path';

// Define custom types
const FileType = new GraphQLObjectType({
  name: 'File',
  fields: {
    filename: { type: GraphQLString },
    mimetype: { type: GraphQLString },
    encoding: { type: GraphQLString },
    url: { type: GraphQLString },
  },
});

// Create schema with mutations
const schema = new GraphQLSchema({
  query: new GraphQLObjectType({
    name: 'Query',
    fields: {
      hello: {
        type: GraphQLString,
        resolve: () => 'Hello World',
      },
    },
  }),
  mutation: new GraphQLObjectType({
    name: 'Mutation',
    fields: {
      // Single file upload
      singleUpload: {
        type: FileType,
        args: {
          file: { 
            type: new GraphQLNonNull(GraphQLUpload),
          },
        },
        async resolve(_, { file }) {
          const { filename, mimetype, encoding, createReadStream } = await file;
          
          // Create upload directory if it doesn't exist
          const uploadDir = path.join(__dirname, 'uploads');
          if (!fs.existsSync(uploadDir)) {
            fs.mkdirSync(uploadDir, { recursive: true });
          }
          
          // Save file to filesystem
          const filePath = path.join(uploadDir, filename);
          const stream = createReadStream();
          const writeStream = fs.createWriteStream(filePath);
          stream.pipe(writeStream);
          
          await new Promise((resolve, reject) => {
            writeStream.on('finish', resolve);
            writeStream.on('error', reject);
          });
          
          return {
            filename,
            mimetype,
            encoding,
            url: `/uploads/${filename}`,
          };
        },
      },
      
      // Multiple file uploads
      multipleUpload: {
        type: new GraphQLList(FileType),
        args: {
          files: { 
            type: new GraphQLNonNull(
              new GraphQLList(new GraphQLNonNull(GraphQLUpload))
            ),
          },
        },
        async resolve(_, { files }) {
          const uploadedFiles = [];
          
          for (const file of files) {
            const { filename, mimetype, encoding, createReadStream } = await file;
            // Process each file...
            uploadedFiles.push({ filename, mimetype, encoding });
          }
          
          return uploadedFiles;
        },
      },
    },
  }),
});

Express + Apollo Server v4

Click to expand example

Complete setup with Apollo Server v4 and Express:

import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { graphqlUploadExpress, GraphQLUpload } from 'graphql-upload-ts';
import { createServer } from 'http';
import cors from 'cors';
import bodyParser from 'body-parser';

// Type definitions
const typeDefs = `#graphql
  scalar Upload

  type File {
    filename: String!
    mimetype: String!
    encoding: String!
    url: String!
  }

  type Query {
    hello: String
  }

  type Mutation {
    singleUpload(file: Upload!): File!
    multipleUpload(files: [Upload!]!): [File!]!
  }
`;

// Resolvers
const resolvers = {
  Upload: GraphQLUpload,
  Query: {
    hello: () => 'Hello world!',
  },
  Mutation: {
    singleUpload: async (parent, { file }) => {
      const { createReadStream, filename, mimetype, encoding } = await file;
      
      // Stream file to cloud storage, filesystem, etc.
      const stream = createReadStream();
      
      // Example: Save to filesystem
      const path = require('path');
      const fs = require('fs');
      const out = fs.createWriteStream(path.join(__dirname, 'uploads', filename));
      stream.pipe(out);
      
      await new Promise((resolve, reject) => {
        out.on('finish', resolve);
        out.on('error', reject);
      });
      
      return {
        filename,
        mimetype,
        encoding,
        url: `/uploads/${filename}`,
      };
    },
    
    multipleUpload: async (parent, { files }) => {
      const uploadedFiles = [];
      
      for (const file of files) {
        const { createReadStream, filename, mimetype, encoding } = await file;
        // Process each file
        uploadedFiles.push({
          filename,
          mimetype,
          encoding,
          url: `/uploads/${filename}`,
        });
      }
      
      return uploadedFiles;
    },
  },
};

// Server setup
async function startServer() {
  const app = express();
  const httpServer = createServer(app);
  
  const server = new ApolloServer({
    typeDefs,
    resolvers,
    plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
  });
  
  await server.start();
  
  // Apply upload middleware BEFORE Apollo Server
  app.use(
    '/graphql',
    cors(),
    bodyParser.json(),
    graphqlUploadExpress({
      maxFileSize: 10 * 1024 * 1024, // 10 MB
      maxFiles: 5,
    }),
    expressMiddleware(server, {
      context: async ({ req }) => ({ token: req.headers.token }),
    })
  );
  
  await new Promise((resolve) => httpServer.listen({ port: 4000 }, resolve));
  console.log('πŸš€ Server ready at http://localhost:4000/graphql');
}

startServer();

Koa + Apollo Server

Click to expand example

Complete Koa setup with Apollo Server:

import Koa from 'koa';
import Router from '@koa/router';
import { ApolloServer } from '@apollo/server';
import { koaMiddleware } from '@as-integrations/koa';
import { graphqlUploadKoa, GraphQLUpload } from 'graphql-upload-ts';
import { createServer } from 'http';

const typeDefs = `#graphql
  scalar Upload

  type File {
    filename: String!
    mimetype: String!
    encoding: String!
  }

  type Query {
    hello: String
  }

  type Mutation {
    uploadFile(file: Upload!): File!
    uploadFiles(files: [Upload!]!): [File!]!
  }
`;

const resolvers = {
  Upload: GraphQLUpload,
  Query: {
    hello: () => 'Hello from Koa!',
  },
  Mutation: {
    uploadFile: async (_, { file }) => {
      const { filename, mimetype, encoding, createReadStream } = await file;
      
      // Process file stream
      const stream = createReadStream();
      
      // Example: Upload to S3
      // const { S3 } = require('@aws-sdk/client-s3');
      // const { Upload } = require('@aws-sdk/lib-storage');
      // const s3 = new S3({ region: 'us-east-1' });
      // 
      // const upload = new Upload({
      //   client: s3,
      //   params: {
      //     Bucket: 'my-bucket',
      //     Key: filename,
      //     Body: stream,
      //     ContentType: mimetype,
      //   },
      // });
      // 
      // await upload.done();
      
      return { filename, mimetype, encoding };
    },
    
    uploadFiles: async (_, { files }) => {
      const uploadPromises = files.map(async (file) => {
        const { filename, mimetype, encoding, createReadStream } = await file;
        // Process each file
        return { filename, mimetype, encoding };
      });
      
      return Promise.all(uploadPromises);
    },
  },
};

async function startServer() {
  const app = new Koa();
  const router = new Router();
  const httpServer = createServer(app.callback());
  
  const server = new ApolloServer({
    typeDefs,
    resolvers,
  });
  
  await server.start();
  
  // Apply upload middleware
  app.use(graphqlUploadKoa({
    maxFileSize: 10 * 1024 * 1024, // 10 MB
    maxFiles: 5,
  }));
  
  // Apply Apollo Server middleware
  router.all(
    '/graphql',
    koaMiddleware(server, {
      context: async ({ ctx }) => ({ token: ctx.headers.token }),
    })
  );
  
  app.use(router.routes());
  app.use(router.allowedMethods());
  
  httpServer.listen(4000, () => {
    console.log('πŸš€ Server ready at http://localhost:4000/graphql');
  });
}

startServer();

Express + GraphQL Yoga

Click to expand example

Setup with GraphQL Yoga for a modern GraphQL server:

import express from 'express';
import { createYoga, createSchema } from 'graphql-yoga';
import { graphqlUploadExpress, GraphQLUpload } from 'graphql-upload-ts';

const schema = createSchema({
  typeDefs: /* GraphQL */ `
    scalar Upload
    
    type File {
      filename: String!
      mimetype: String!
      encoding: String!
      content: String!
    }
    
    type Query {
      hello: String
    }
    
    type Mutation {
      readTextFile(file: Upload!): File!
      uploadImage(file: Upload!): File!
      uploadDocuments(files: [Upload!]!): [File!]!
    }
  `,
  resolvers: {
    Upload: GraphQLUpload,
    Query: {
      hello: () => 'Hello from Yoga!',
    },
    Mutation: {
      readTextFile: async (_, { file }) => {
        const { filename, mimetype, encoding, createReadStream } = await file;
        
        // Read text file content
        const stream = createReadStream();
        const chunks = [];
        
        for await (const chunk of stream) {
          chunks.push(chunk);
        }
        
        const content = Buffer.concat(chunks).toString('utf-8');
        
        return {
          filename,
          mimetype,
          encoding,
          content,
        };
      },
      
      uploadImage: async (_, { file }) => {
        const { filename, mimetype, encoding, createReadStream } = await file;
        
        // Validate image
        if (!mimetype.startsWith('image/')) {
          throw new Error('File must be an image');
        }
        
        const stream = createReadStream();
        
        // Example: Process with sharp for image manipulation
        // const sharp = require('sharp');
        // const processedImage = await sharp(stream)
        //   .resize(800, 600)
        //   .jpeg({ quality: 80 })
        //   .toBuffer();
        
        return {
          filename,
          mimetype,
          encoding,
          content: 'Image processed successfully',
        };
      },
      
      uploadDocuments: async (_, { files }) => {
        const results = [];
        
        for (const file of files) {
          const { filename, mimetype, encoding } = await file;
          results.push({
            filename,
            mimetype,
            encoding,
            content: `Document ${filename} uploaded`,
          });
        }
        
        return results;
      },
    },
  },
});

const app = express();

// Apply upload middleware BEFORE yoga
app.use(graphqlUploadExpress({
  maxFileSize: 10 * 1024 * 1024, // 10 MB
  maxFiles: 10,
}));

// Create and use Yoga
const yoga = createYoga({ 
  schema,
  graphiql: {
    title: 'GraphQL Yoga with File Uploads',
  },
});

app.use('/graphql', yoga);

app.listen(4000, () => {
  console.log('🧘 Server is running on http://localhost:4000/graphql');
});

NestJS Integration

Click to expand example

For NestJS applications, you need special configuration:

// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { graphqlUploadExpress } from 'graphql-upload-ts';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: true,
      // Disable built-in upload handling
      uploads: false,
    }),
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(
        graphqlUploadExpress({
          maxFileSize: 10 * 1024 * 1024, // 10 MB
          maxFiles: 5,
          // Important for NestJS!
          overrideSendResponse: false,
        })
      )
      .forRoutes('graphql');
  }
}

// upload.resolver.ts
import { Resolver, Mutation, Args } from '@nestjs/graphql';
import { GraphQLUpload, FileUpload } from 'graphql-upload-ts';
import { createWriteStream } from 'fs';

@Resolver()
export class UploadResolver {
  @Mutation(() => Boolean)
  async uploadFile(
    @Args({ name: 'file', type: () => GraphQLUpload })
    file: Promise<FileUpload>,
  ): Promise<boolean> {
    const { createReadStream, filename } = await file;
    
    return new Promise((resolve, reject) => {
      createReadStream()
        .pipe(createWriteStream(`./uploads/${filename}`))
        .on('finish', () => resolve(true))
        .on('error', reject);
    });
  }
}

TypeGraphQL Integration

Click to expand example

For TypeGraphQL, you need to create a custom scalar wrapper:

// upload.scalar.ts
import { GraphQLUpload } from 'graphql-upload-ts';
import { Scalar, CustomScalar } from 'type-graphql';
import { GraphQLScalarType, GraphQLError } from 'graphql';

// Create a custom Upload scalar for TypeGraphQL
@Scalar('Upload')
export class UploadScalar implements CustomScalar<any, any> {
  description = 'The `Upload` scalar type represents a file upload.';

  parseValue(value: any) {
    return GraphQLUpload.parseValue(value);
  }

  serialize(value: any) {
    return GraphQLUpload.serialize(value);
  }

  parseLiteral(ast: any) {
    return GraphQLUpload.parseLiteral(ast, null);
  }
}

// Define the FileUpload type for TypeScript
import { Stream } from 'stream';
import { Field, ObjectType, InputType } from 'type-graphql';

interface Upload {
  filename: string;
  mimetype: string;
  encoding: string;
  createReadStream: () => Stream;
}

// Output type for file information
@ObjectType()
export class FileInfo {
  @Field()
  filename: string;

  @Field()
  mimetype: string;

  @Field()
  encoding: string;

  @Field()
  url: string;
}

// Input type for mutations with files and additional fields
@InputType()
export class CreatePostInput {
  @Field()
  title: string;

  @Field()
  content: string;

  @Field(() => [String], { nullable: true })
  tags?: string[];

  // Note: File upload fields are handled separately in resolver args
}

// resolver.ts
import { Resolver, Mutation, Arg, Query } from 'type-graphql';
import { GraphQLUpload } from 'graphql-upload-ts';
import { FileInfo, CreatePostInput } from './types';
import { createWriteStream } from 'fs';
import path from 'path';

@Resolver()
export class PostResolver {
  // Simple file upload
  @Mutation(() => FileInfo)
  async uploadFile(
    @Arg('file', () => GraphQLUpload)
    file: Promise<Upload>
  ): Promise<FileInfo> {
    const { filename, mimetype, encoding, createReadStream } = await file;
    
    // Save file to disk
    const savePath = path.join(__dirname, 'uploads', filename);
    const stream = createReadStream();
    const writeStream = createWriteStream(savePath);
    stream.pipe(writeStream);
    
    await new Promise((resolve, reject) => {
      writeStream.on('finish', resolve);
      writeStream.on('error', reject);
    });
    
    return {
      filename,
      mimetype,
      encoding,
      url: `/uploads/${filename}`,
    };
  }

  // File upload with additional form fields
  @Mutation(() => Boolean)
  async createPostWithImage(
    @Arg('data') data: CreatePostInput,
    @Arg('image', () => GraphQLUpload)
    image: Promise<Upload>,
    @Arg('thumbnail', () => GraphQLUpload, { nullable: true })
    thumbnail?: Promise<Upload>
  ): Promise<boolean> {
    // Process the main image
    const mainImage = await image;
    const { filename, createReadStream } = mainImage;
    
    // Save the main image
    const imagePath = path.join(__dirname, 'uploads', 'posts', filename);
    const imageStream = createReadStream();
    const imageWriteStream = createWriteStream(imagePath);
    imageStream.pipe(imageWriteStream);
    
    // Process thumbnail if provided
    if (thumbnail) {
      const thumbFile = await thumbnail;
      const thumbPath = path.join(__dirname, 'uploads', 'posts', 'thumbnails', thumbFile.filename);
      const thumbStream = thumbFile.createReadStream();
      const thumbWriteStream = createWriteStream(thumbPath);
      thumbStream.pipe(thumbWriteStream);
    }
    
    // Save post data to database
    console.log('Creating post with:', {
      title: data.title,
      content: data.content,
      tags: data.tags,
      imagePath,
    });
    
    // In a real app, save to database here
    
    return true;
  }

  // Multiple file uploads with metadata
  @Mutation(() => [FileInfo])
  async uploadMultipleFiles(
    @Arg('files', () => [GraphQLUpload])
    files: Promise<Upload>[],
    @Arg('descriptions', () => [String], { nullable: true })
    descriptions?: string[]
  ): Promise<FileInfo[]> {
    const uploadedFiles: FileInfo[] = [];
    
    for (let i = 0; i < files.length; i++) {
      const file = await files[i];
      const { filename, mimetype, encoding, createReadStream } = file;
      const description = descriptions?.[i] || '';
      
      // Save each file
      const savePath = path.join(__dirname, 'uploads', filename);
      const stream = createReadStream();
      const writeStream = createWriteStream(savePath);
      stream.pipe(writeStream);
      
      await new Promise((resolve, reject) => {
        writeStream.on('finish', resolve);
        writeStream.on('error', reject);
      });
      
      // Store metadata if needed
      console.log(`File ${filename} uploaded with description: ${description}`);
      
      uploadedFiles.push({
        filename,
        mimetype,
        encoding,
        url: `/uploads/${filename}`,
      });
    }
    
    return uploadedFiles;
  }
}

// server.ts - Setting up the server
import 'reflect-metadata';
import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { buildSchema } from 'type-graphql';
import { graphqlUploadExpress } from 'graphql-upload-ts';
import { PostResolver } from './resolver';
import { UploadScalar } from './upload.scalar';

async function bootstrap() {
  // Build TypeGraphQL schema
  const schema = await buildSchema({
    resolvers: [PostResolver],
    scalarsMap: [{ type: Object, scalar: UploadScalar }],
  });

  // Create Apollo Server
  const server = new ApolloServer({ schema });
  await server.start();

  // Create Express app
  const app = express();

  // IMPORTANT: Apply upload middleware BEFORE Apollo Server
  app.use(
    '/graphql',
    graphqlUploadExpress({
      maxFileSize: 10 * 1024 * 1024, // 10 MB
      maxFiles: 10,
    }),
    express.json(),
    expressMiddleware(server)
  );

  app.listen(4000, () => {
    console.log('Server is running on http://localhost:4000/graphql');
  });
}

bootstrap();

Example GraphQL Mutations

# Simple file upload
mutation UploadFile($file: Upload!) {
  uploadFile(file: $file) {
    filename
    mimetype
    url
  }
}

# Upload with additional fields
mutation CreatePost($data: CreatePostInput!, $image: Upload!, $thumbnail: Upload) {
  createPostWithImage(data: $data, image: $image, thumbnail: $thumbnail)
}

# Multiple files with descriptions
mutation UploadMultiple($files: [Upload!]!, $descriptions: [String!]) {
  uploadMultipleFiles(files: $files, descriptions: $descriptions) {
    filename
    url
  }
}

Client-Side Example (using Apollo Client)

import { gql, useMutation } from '@apollo/client';

const UPLOAD_WITH_DATA = gql`
  mutation CreatePost($data: CreatePostInput!, $image: Upload!) {
    createPostWithImage(data: $data, image: $image)
  }
`;

function PostForm() {
  const [createPost] = useMutation(UPLOAD_WITH_DATA);

  const handleSubmit = async (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    
    const file = formData.get('image');
    const data = {
      title: formData.get('title'),
      content: formData.get('content'),
      tags: formData.get('tags').split(','),
    };

    await createPost({
      variables: {
        data,
        image: file,
      },
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Post Title" required />
      <textarea name="content" placeholder="Content" required />
      <input name="tags" placeholder="Tags (comma-separated)" />
      <input name="image" type="file" required />
      <button type="submit">Create Post</button>
    </form>
  );
}

Image Upload with Validation

Click to expand example

Complete example with image validation, resizing, and cloud storage:

import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import { graphqlUploadExpress, GraphQLUpload } from 'graphql-upload-ts';
import { validateMimeType, validateFileExtension, sanitizeFilename } from 'graphql-upload-ts';
import sharp from 'sharp';
import { S3 } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import crypto from 'crypto';

const schema = buildSchema(`
  scalar Upload
  
  type Image {
    id: String!
    originalName: String!
    filename: String!
    mimetype: String!
    size: Int!
    width: Int!
    height: Int!
    url: String!
    thumbnailUrl: String!
  }
  
  type Mutation {
    uploadProfileImage(file: Upload!): Image!
    uploadGalleryImages(files: [Upload!]!): [Image!]!
  }
  
  type Query {
    hello: String
  }
`);

const s3 = new S3({ region: process.env.AWS_REGION });

const resolvers = {
  Upload: GraphQLUpload,
  
  Mutation: {
    uploadProfileImage: async (_, { file }) => {
      const { filename, mimetype, createReadStream } = await file;
      
      // Validate image type
      const mimeValidation = validateMimeType(mimetype, [
        'image/jpeg',
        'image/png',
        'image/webp',
      ]);
      
      if (!mimeValidation.isValid) {
        throw new Error(mimeValidation.error);
      }
      
      // Validate file extension
      const extValidation = validateFileExtension(filename, [
        '.jpg',
        '.jpeg',
        '.png',
        '.webp',
      ]);
      
      if (!extValidation.isValid) {
        throw new Error(extValidation.error);
      }
      
      // Sanitize filename
      const sanitized = sanitizeFilename(filename);
      const uniqueFilename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}-${sanitized}`;
      
      // Read the stream into a buffer for processing
      const stream = createReadStream();
      const chunks = [];
      for await (const chunk of stream) {
        chunks.push(chunk);
      }
      const buffer = Buffer.concat(chunks);
      
      // Process image with sharp
      const image = sharp(buffer);
      const metadata = await image.metadata();
      
      // Validate image dimensions
      if (metadata.width < 100 || metadata.height < 100) {
        throw new Error('Image must be at least 100x100 pixels');
      }
      
      // Create main image (max 1920x1080)
      const mainImage = await image
        .resize(1920, 1080, {
          fit: 'inside',
          withoutEnlargement: true,
        })
        .jpeg({ quality: 85, progressive: true })
        .toBuffer();
      
      // Create thumbnail (200x200)
      const thumbnail = await sharp(buffer)
        .resize(200, 200, {
          fit: 'cover',
          position: 'center',
        })
        .jpeg({ quality: 80 })
        .toBuffer();
      
      // Upload main image to S3
      const mainUpload = new Upload({
        client: s3,
        params: {
          Bucket: process.env.S3_BUCKET,
          Key: `images/${uniqueFilename}`,
          Body: mainImage,
          ContentType: 'image/jpeg',
          CacheControl: 'max-age=31536000',
        },
      });
      
      // Upload thumbnail to S3
      const thumbUpload = new Upload({
        client: s3,
        params: {
          Bucket: process.env.S3_BUCKET,
          Key: `thumbnails/${uniqueFilename}`,
          Body: thumbnail,
          ContentType: 'image/jpeg',
          CacheControl: 'max-age=31536000',
        },
      });
      
      const [mainResult, thumbResult] = await Promise.all([
        mainUpload.done(),
        thumbUpload.done(),
      ]);
      
      return {
        id: crypto.randomUUID(),
        originalName: filename,
        filename: uniqueFilename,
        mimetype: 'image/jpeg',
        size: mainImage.length,
        width: metadata.width,
        height: metadata.height,
        url: mainResult.Location,
        thumbnailUrl: thumbResult.Location,
      };
    },
    
    uploadGalleryImages: async (_, { files }) => {
      const uploadPromises = files.map(async (filePromise) => {
        const file = await filePromise;
        // Process each image similarly
        // ... implementation
      });
      
      return Promise.all(uploadPromises);
    },
  },
};

const app = express();

// Configure upload middleware with strict limits for images
app.use(
  '/graphql',
  graphqlUploadExpress({
    maxFileSize: 5 * 1024 * 1024, // 5 MB max for images
    maxFiles: 10, // Max 10 images at once
  }),
  graphqlHTTP({
    schema,
    rootValue: resolvers,
    graphiql: true,
  })
);

app.listen(4000, () => {
  console.log('Image upload server running on http://localhost:4000/graphql');
});

πŸ“– API Documentation

Middleware Functions

graphqlUploadExpress(options?)

Express middleware for handling multipart/form-data requests.

import { graphqlUploadExpress } from 'graphql-upload-ts';

app.use('/graphql', graphqlUploadExpress({
  maxFileSize: 10000000,  // 10 MB for file uploads (default: 5 MB)
  maxFiles: 10,           // Max number of files (default: Infinity)
  maxFieldSize: 1000000,  // 1 MB for JSON fields (default: 1 MB)
}));

graphqlUploadKoa(options?)

Koa middleware for handling multipart/form-data requests.

import { graphqlUploadKoa } from 'graphql-upload-ts';

app.use(graphqlUploadKoa({
  maxFileSize: 10000000, // 10 MB
  maxFiles: 10,
}));

Types

FileUpload

The promise returned from uploaded files contains:

interface FileUpload {
  filename: string;
  mimetype: string;
  encoding: string;
  fieldName: string;
  createReadStream: (options?: ReadStreamOptions) => NodeJS.ReadableStream;
}

interface ReadStreamOptions {
  encoding?: BufferEncoding;
  highWaterMark?: number;
}

UploadOptions

Configuration options for the middleware:

interface UploadOptions {
  maxFieldSize?: number;  // Max size of non-file fields like JSON (default: 1 MB)
  maxFileSize?: number;   // Max size per file upload (default: 5 MB) 
  maxFiles?: number;      // Max number of files (default: Infinity)
}

Scalar Type

GraphQLUpload

The GraphQL scalar type for file uploads. Use it in your schema:

import { GraphQLUpload } from 'graphql-upload-ts';

// For schema-first approach (SDL)
const resolvers = {
  Upload: GraphQLUpload,
  // ... other resolvers
};

// For code-first approach
import { GraphQLScalarType } from 'graphql';
const Upload: GraphQLScalarType = GraphQLUpload;

πŸ›‘οΈ Security & Validation

Built-in Protections

The library includes several security features:

  • File size limits - Prevent large file DoS attacks
  • File count limits - Restrict number of concurrent uploads
  • Field size limits - Limit non-file field sizes
  • Filename sanitization - Remove unsafe characters from filenames
  • MIME type validation - Optional MIME type restrictions

Validation Utilities

import { 
  validateMimeType, 
  validateFileExtension, 
  sanitizeFilename 
} from 'graphql-upload-ts';

// Validate MIME type
const mimeResult = validateMimeType(mimetype, ['image/jpeg', 'image/png']);
if (!mimeResult.isValid) {
  throw new Error(mimeResult.error);
}

// Validate file extension
const extResult = validateFileExtension(filename, ['.jpg', '.jpeg', '.png']);
if (!extResult.isValid) {
  throw new Error(extResult.error);
}

// Sanitize filename for safe storage
const safe = sanitizeFilename('../../dangerous/file name!.txt');
// Returns: "dangerous-file-name.txt"

Error Handling

The library provides custom error classes:

import { UploadError, UploadErrorCode } from 'graphql-upload-ts';

try {
  // Upload logic
} catch (error) {
  if (error instanceof UploadError) {
    switch (error.code) {
      case UploadErrorCode.FILE_TOO_LARGE:
        // Handle large file
        break;
      case UploadErrorCode.INVALID_FILE_TYPE:
        // Handle invalid type
        break;
      // ... handle other cases
    }
  }
}

Error codes available:

  • FILE_TOO_LARGE - File exceeds maxFileSize
  • TOO_MANY_FILES - Too many files uploaded
  • INVALID_FILE_TYPE - File type not allowed
  • STREAM_ERROR - Error reading file stream
  • FIELD_SIZE_EXCEEDED - Non-file field too large
  • MISSING_MULTIPART_BOUNDARY - Invalid request format
  • INVALID_MULTIPART_REQUEST - Malformed multipart request

πŸ—οΈ Architecture

The library uses a streaming architecture for efficient file handling:

  1. Request Parsing - busboy parses multipart requests
  2. File Buffering - Files are buffered to filesystem using fs-capacitor
  3. Promise Resolution - Upload promises resolve with file details
  4. Stream Creation - Resolvers can create multiple read streams from buffered files
  5. Cleanup - Temporary files are automatically cleaned up after response

This architecture allows:

  • Processing files in any order
  • Multiple reads of the same file
  • Backpressure handling
  • Automatic cleanup

πŸ”„ Migration Guide

From graphql-upload v15+

This library is a TypeScript-first alternative with similar API:

// Before (graphql-upload)
const { graphqlUploadExpress } = require('graphql-upload');
const { GraphQLUpload } = require('graphql-upload');

// After (graphql-upload-ts)
import { graphqlUploadExpress, GraphQLUpload } from 'graphql-upload-ts';

Main differences:

  • Full TypeScript support with strict types
  • CommonJS build for maximum compatibility
  • Built-in validation utilities
  • Custom error classes
  • Modern Node.js features (16+)

Important Notes

  1. Middleware Order: Always apply the upload middleware BEFORE your GraphQL middleware
  2. File Processing: Process uploads inside resolvers, not after response
  3. Stream Handling: Always consume or destroy streams to prevent memory leaks
  4. Error Handling: Implement proper error handling for failed uploads
  5. NestJS: Use overrideSendResponse: false option

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.

Development

# Install dependencies
npm install

# Run tests
npm test

# Run tests with coverage
npm run test:coverage

# Build the library (using Rollup)
npm run build

# Run linting (using Biome)
npm run lint

# Format code (using Biome)
npm run format

# Type checking
npm run typecheck

Build Configuration

The project uses:

  • Rollup - For bundling the TypeScript source into CommonJS format
  • Biome - For linting and formatting (replacing ESLint and Prettier)
  • Jest - For testing with comprehensive coverage
  • TypeScript - With strict mode enabled for type safety

πŸ“„ License

MIT Β© Mohamed Meabed

πŸ™ Acknowledgments

This library is a TypeScript fork of graphql-upload by Jayden Seric. The original library was exceptionally well designed, and this fork aims to maintain that quality while adding TypeScript support and modern features.

πŸ”— Links


Made with ❀️ by Mohamed Meabed

About

Middleware and an Upload scalar to add support for GraphQL multipart requests (file uploads via queries and mutations) to various Node.js GraphQL servers.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

No packages published

Contributors 18