diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..709c196 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "typescript.disableAutomaticTypeAcquisition": true, + "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/GRAPHQL_MULTIPART_REQUEST_SPEC_URL.js b/GRAPHQL_MULTIPART_REQUEST_SPEC_URL.js index 7648c26..f5730ff 100644 --- a/GRAPHQL_MULTIPART_REQUEST_SPEC_URL.js +++ b/GRAPHQL_MULTIPART_REQUEST_SPEC_URL.js @@ -1,12 +1,12 @@ +// @ts-check + "use strict"; /** * [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec) * URL. Useful for error messages, etc. - * @kind constant - * @name GRAPHQL_MULTIPART_REQUEST_SPEC_URL - * @type {string} - * @ignore */ -module.exports = +const GRAPHQL_MULTIPART_REQUEST_SPEC_URL = "https://github.com/jaydenseric/graphql-multipart-request-spec"; + +module.exports = GRAPHQL_MULTIPART_REQUEST_SPEC_URL; diff --git a/GraphQLUpload.js b/GraphQLUpload.js index df76fb5..97ca7b4 100644 --- a/GraphQLUpload.js +++ b/GraphQLUpload.js @@ -1,30 +1,28 @@ +// @ts-check + "use strict"; const { GraphQLScalarType, GraphQLError } = require("graphql"); const Upload = require("./Upload.js"); +/** @typedef {import("./processRequest").FileUpload} FileUpload */ + /** * A GraphQL `Upload` scalar that can be used in a - * [`GraphQLSchema`](https://graphql.org/graphql-js/type/#graphqlschema). - * It’s value in resolvers is a promise that resolves - * [file upload details]{@link FileUpload} for processing and storage. - * @kind class - * @name GraphQLUpload - * @example How to `import`. - * ```js - * import GraphQLUpload from "graphql-upload/GraphQLUpload.js"; - * ``` - * @example How to `require`. - * ```js - * const GraphQLUpload = require("graphql-upload/GraphQLUpload.js"); - * ``` - * @example A schema built using [`makeExecutableSchema`](https://www.graphql-tools.com/docs/api/modules/schema#makeexecutableschema) from [`@graphql-tools/schema`](https://npm.im/@graphql-tools/schema). + * [`GraphQLSchema`](https://graphql.org/graphql-js/type/#graphqlschema). It’s + * value in resolvers is a promise that resolves + * {@link FileUpload file upload details} for processing and storage. + * @example + * A schema built using + * [`makeExecutableSchema`](https://www.graphql-tools.com/docs/api/modules/schema#makeexecutableschema) + * from [`@graphql-tools/schema`](https://npm.im/@graphql-tools/schema): + * * ```js * const { makeExecutableSchema } = require("@graphql-tools/schema"); * const GraphQLUpload = require("graphql-upload/GraphQLUpload.js"); * * const schema = makeExecutableSchema({ - * typeDefs: /* GraphQL *\/ ` + * typeDefs: ` * scalar Upload * `, * resolvers: { @@ -32,7 +30,9 @@ const Upload = require("./Upload.js"); * }, * }); * ``` - * @example A manually constructed schema with an image upload mutation. + * @example + * A manually constructed schema with an image upload mutation: + * * ```js * const { GraphQLSchema, GraphQLObjectType, GraphQLBoolean } = require("graphql"); * const GraphQLUpload = require("graphql-upload/GraphQLUpload.js"); @@ -62,7 +62,7 @@ const Upload = require("./Upload.js"); * }); * ``` */ -module.exports = new GraphQLScalarType({ +const GraphQLUpload = new GraphQLScalarType({ name: "Upload", description: "The `Upload` scalar type represents a file upload.", parseValue(value) { @@ -76,3 +76,5 @@ module.exports = new GraphQLScalarType({ throw new GraphQLError("Upload serialization unsupported."); }, }); + +module.exports = GraphQLUpload; diff --git a/GraphQLUpload.test.mjs b/GraphQLUpload.test.mjs index bb3cb2e..17b51b2 100644 --- a/GraphQLUpload.test.mjs +++ b/GraphQLUpload.test.mjs @@ -1,9 +1,15 @@ +// @ts-check + import { doesNotThrow, throws } from "assert"; import { parseValue } from "graphql"; import GraphQLUpload from "./GraphQLUpload.js"; import Upload from "./Upload.js"; +/** + * Adds `GraphQLUpload` tests. + * @param {import("test-director").default} tests Test director. + */ export default (tests) => { tests.add("`GraphQLUpload` scalar `parseValue` with a valid value.", () => { doesNotThrow(() => { diff --git a/Upload.js b/Upload.js index 3c95213..3c577a9 100644 --- a/Upload.js +++ b/Upload.js @@ -1,49 +1,37 @@ +// @ts-check + "use strict"; +/** @typedef {import("./GraphQLUpload.js")} GraphQLUpload */ +/** @typedef {import("./processRequest.js")} processRequest */ + /** - * A file expected to be uploaded as it has been declared in the `map` field of - * a [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec). - * The [`processRequest`]{@link processRequest} function places references to an - * instance of this class wherever the file is expected in the - * [GraphQL operation]{@link GraphQLOperation}. The - * [`Upload`]{@link GraphQLUpload} scalar derives it’s value from the - * [`promise`]{@link Upload#promise} property. - * @kind class - * @name Upload - * @example How to `import`. - * ```js - * import Upload from "graphql-upload/Upload.js"; - * ``` - * @example How to `require`. - * ```js - * const Upload = require("graphql-upload/Upload.js"); - * ``` + * A file expected to be uploaded as it was declared in the `map` field of a + * [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec). + * The {@linkcode processRequest} function places references to an instance of + * this class wherever the file is expected in the GraphQL operation. The scalar + * {@linkcode GraphQLUpload} derives it’s value from {@linkcode Upload.promise}. */ -module.exports = class Upload { +class Upload { constructor() { /** * Promise that resolves file upload details. This should only be utilized - * by [`GraphQLUpload`]{@link GraphQLUpload}. - * @kind member - * @name Upload#promise - * @type {Promise} + * by {@linkcode GraphQLUpload}. + * @type {Promise} */ this.promise = new Promise((resolve, reject) => { /** * Resolves the upload promise with the file upload details. This should - * only be utilized by [`processRequest`]{@link processRequest}. - * @kind function - * @name Upload#resolve - * @param {FileUpload} file File upload details. + * only be utilized by {@linkcode processRequest}. + * @param {import("./processRequest.js").FileUpload} file File upload + * details. */ this.resolve = (file) => { /** * The file upload details, available when the - * [upload promise]{@link Upload#promise} resolves. This should only be - * utilized by [`processRequest`]{@link processRequest}. - * @kind member - * @name Upload#file - * @type {undefined|FileUpload} + * {@linkcode Upload.promise} resolves. This should only be utilized by + * {@linkcode processRequest}. + * @type {import("./processRequest.js").FileUpload | undefined} */ this.file = file; @@ -52,10 +40,8 @@ module.exports = class Upload { /** * Rejects the upload promise with an error. This should only be - * utilized by [`processRequest`]{@link processRequest}. - * @kind function - * @name Upload#reject - * @param {object} error Error instance. + * utilized by {@linkcode processRequest}. + * @param {Error} error Error instance. */ this.reject = reject; }); @@ -64,34 +50,6 @@ module.exports = class Upload { // https://github.com/nodejs/node/issues/20392 this.promise.catch(() => {}); } -}; +} -/** - * File upload details that are only available after the file’s field in the - * [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec) - * has begun streaming in. - * @kind typedef - * @name FileUpload - * @type {object} - * @prop {string} filename File name. - * @prop {string} mimetype File MIME type. Provided by the client and can’t be trusted. - * @prop {string} encoding File stream transfer encoding. - * @prop {FileUploadCreateReadStream} createReadStream Creates a [Node.js readable stream](https://nodejs.org/api/stream.html#stream_readable_streams) of the file’s contents, for processing and storage. - */ - -/** - * Creates a - * [Node.js readable stream](https://nodejs.org/api/stream.html#stream_readable_streams) - * of an [uploading file’s]{@link FileUpload} contents, for processing and - * storage. Multiple calls create independent streams. Throws if called after - * all resolvers have resolved, or after an error has interrupted the request. - * @kind typedef - * @name FileUploadCreateReadStream - * @type {Function} - * @param {object} [options] [`fs-capacitor`](https://npm.im/fs-capacitor) [`ReadStreamOptions`](https://github.com/mike-marcacci/fs-capacitor#readstreamoptions). - * @param {string} [options.encoding=null] Specify an encoding for the [`data`](https://nodejs.org/api/stream.html#stream_event_data) chunks to be strings (without splitting multi-byte characters across chunks) instead of Node.js [`Buffer`](https://nodejs.org/api/buffer.html#buffer_buffer) instances. Supported values depend on the [`Buffer` implementation](https://github.com/nodejs/node/blob/v13.7.0/lib/buffer.js#L587-L663) and include `utf8`, `ucs2`, `utf16le`, `latin1`, `ascii`, `base64`, or `hex`. - * @param {number} [options.highWaterMark=16384] Maximum number of bytes to store in the internal buffer before ceasing to read from the underlying resource. - * @returns {Readable} [Node.js readable stream](https://nodejs.org/api/stream.html#stream_readable_streams) of the file’s contents. - * @see [Node.js `Readable` stream constructor docs](https://nodejs.org/api/stream.html#stream_new_stream_readable_options). - * @see [Node.js stream backpressure guide](https://nodejs.org/en/docs/guides/backpressuring-in-streams). - */ +module.exports = Upload; diff --git a/Upload.test.mjs b/Upload.test.mjs index 068a47c..ed3c938 100644 --- a/Upload.test.mjs +++ b/Upload.test.mjs @@ -1,7 +1,13 @@ +// @ts-check + import { ok, rejects, strictEqual } from "assert"; import Upload from "./Upload.js"; +/** + * Adds `Upload` tests. + * @param {import("test-director").default} tests Test director. + */ export default (tests) => { tests.add("`Upload` class resolving a file.", async () => { const upload = new Upload(); @@ -9,6 +15,7 @@ export default (tests) => { ok(upload.promise instanceof Promise); strictEqual(typeof upload.resolve, "function"); + /** @type {any} */ const file = {}; upload.resolve(file); diff --git a/changelog.md b/changelog.md index a61f36a..aded104 100644 --- a/changelog.md +++ b/changelog.md @@ -9,11 +9,14 @@ - Public modules are now individually listed in the package `files` and `exports` fields. - Removed the package main index module; deep imports must be used. - Shortened public module deep import paths, removing the `/public/`. +- Implemented TypeScript types via JSDoc comments, closing [#282](https://github.com/jaydenseric/graphql-upload/issues/282). ### Patch - Updated dev dependencies. - Simplified dev dependencies and config for ESLint. +- Check TypeScript types via a new package `types` script. +- Removed the [`jsdoc-md`](https://npm.im/jsdoc-md) dev dependency and the related package scripts, replacing the readme “API” section with a manually written “Exports” section. - Removed the [`hard-rejection`](https://npm.im/hard-rejection) dev dependency. Instead, tests are run with the Node.js CLI flag `--unhandled-rejections=throw` to make Node.js v14 behave like newer versions. - Removed the [`formdata-node`](https://npm.im/formdata-node) dev dependency. Instead, `File` and `FormData` are imported from [`node-fetch`](https://npm.im/formdata-node). - Updated GitHub Actions CI config: @@ -24,6 +27,7 @@ - Use the `.js` file extension in `require` paths. - Use the Node.js `Readable` property `readableEncoding` instead of `_readableState.encoding` in tests. - Fixed a typo in a code comment. +- Updated documentation. ## 13.0.0 diff --git a/graphqlUploadExpress.js b/graphqlUploadExpress.js index bc9af59..01a512b 100644 --- a/graphqlUploadExpress.js +++ b/graphqlUploadExpress.js @@ -1,28 +1,22 @@ +// @ts-check + "use strict"; const defaultProcessRequest = require("./processRequest.js"); /** - * Creates [Express](https://expressjs.com) middleware that processes + * Creates [Express](https://expressjs.com) middleware that processes incoming * [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec) - * using [`processRequest`]{@link processRequest}, ignoring non-multipart - * requests. It sets the request body to be - * [similar to a conventional GraphQL POST request]{@link GraphQLOperation} for + * using {@linkcode processRequest}, ignoring non multipart requests. It sets + * the request `body` to be similar to a conventional GraphQL POST request for * following GraphQL middleware to consume. - * @kind function - * @name graphqlUploadExpress - * @param {ProcessRequestOptions} options Middleware options. Any [`ProcessRequestOptions`]{@link ProcessRequestOptions} can be used. - * @param {ProcessRequestFunction} [options.processRequest=processRequest] Used to process [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec). - * @returns {Function} Express middleware. - * @example How to `import`. - * ```js - * import graphqlUploadExpress from "graphql-upload/graphqlUploadExpress.js"; - * ``` - * @example How to `require`. - * ```js - * const graphqlUploadExpress = require("graphql-upload/graphqlUploadExpress.js"); - * ``` - * @example Basic [`express-graphql`](https://npm.im/express-graphql) setup. + * @param {import("./processRequest.js").ProcessRequestOptions & { + * processRequest?: import("./processRequest.js").ProcessRequestFunction + * }} options Options. + * @returns Express middleware. + * @example + * Basic [`express-graphql`](https://npm.im/express-graphql) setup: + * * ```js * const express = require("express"); * const graphqlHTTP = require("express-graphql"); @@ -38,22 +32,36 @@ const defaultProcessRequest = require("./processRequest.js"); * .listen(3000); * ``` */ -module.exports = function graphqlUploadExpress({ +function graphqlUploadExpress({ processRequest = defaultProcessRequest, ...processRequestOptions } = {}) { - return function graphqlUploadExpressMiddleware(request, response, next) { + /** + * [Express](https://expressjs.com) middleware that processes incoming + * [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec) + * using {@linkcode processRequest}, ignoring non multipart requests. It sets + * the request `body` to be similar to a conventional GraphQL POST request for + * following GraphQL middleware to consume. + * @param {import("express").Request} request + * @param {import("express").Response} response + * @param {import("express").NextFunction} next + */ + function graphqlUploadExpressMiddleware(request, response, next) { if (!request.is("multipart/form-data")) return next(); - const finished = new Promise((resolve) => request.on("end", resolve)); + const requestEnd = new Promise((resolve) => request.on("end", resolve)); const { send } = response; - response.send = (...args) => { - finished.then(() => { - response.send = send; - response.send(...args); - }); - }; + // @ts-ignore Todo: Find a less hacky way to prevent sending a response + // before the request has ended. + response.send = + /** @param {Array} args */ + (...args) => { + requestEnd.then(() => { + response.send = send; + response.send(...args); + }); + }; processRequest(request, response, processRequestOptions) .then((body) => { @@ -64,5 +72,9 @@ module.exports = function graphqlUploadExpress({ if (error.status && error.expose) response.status(error.status); next(error); }); - }; -}; + } + + return graphqlUploadExpressMiddleware; +} + +module.exports = graphqlUploadExpress; diff --git a/graphqlUploadExpress.test.mjs b/graphqlUploadExpress.test.mjs index 043f9a6..3be23ea 100644 --- a/graphqlUploadExpress.test.mjs +++ b/graphqlUploadExpress.test.mjs @@ -1,5 +1,8 @@ +// @ts-check + import { deepStrictEqual, ok, strictEqual } from "assert"; import express from "express"; +import { createServer } from "http"; import createError from "http-errors"; import fetch, { File, FormData } from "node-fetch"; @@ -7,21 +10,26 @@ import graphqlUploadExpress from "./graphqlUploadExpress.js"; import processRequest from "./processRequest.js"; import listen from "./test/listen.mjs"; +/** + * Adds `graphqlUploadExpress` tests. + * @param {import("test-director").default} tests Test director. + */ export default (tests) => { tests.add( - "`graphqlUploadExpress` with a non-multipart request.", + "`graphqlUploadExpress` with a non multipart request.", async () => { let processRequestRan = false; const app = express().use( graphqlUploadExpress({ + /** @type {any} */ async processRequest() { processRequestRan = true; }, }) ); - const { port, close } = await listen(app); + const { port, close } = await listen(createServer(app)); try { await fetch(`http://localhost:${port}`, { method: "POST" }); @@ -33,6 +41,13 @@ export default (tests) => { ); tests.add("`graphqlUploadExpress` with a multipart request.", async () => { + /** + * @type {{ + * variables: { + * file: import("./Upload.js"), + * }, + * } | undefined} + */ let requestBody; const app = express() @@ -42,7 +57,7 @@ export default (tests) => { next(); }); - const { port, close } = await listen(app); + const { port, close } = await listen(createServer(app)); try { const body = new FormData(); @@ -65,6 +80,14 @@ export default (tests) => { "`graphqlUploadExpress` with a multipart request and option `processRequest`.", async () => { let processRequestRan = false; + + /** + * @type {{ + * variables: { + * file: import("./Upload.js"), + * }, + * } | undefined} + */ let requestBody; const app = express() @@ -81,7 +104,7 @@ export default (tests) => { next(); }); - const { port, close } = await listen(app); + const { port, close } = await listen(createServer(app)); try { const body = new FormData(); @@ -117,6 +140,7 @@ export default (tests) => { .use((request, response, next) => { const { send } = response; + // @ts-ignore Todo: Find a less hacky way. response.send = (...args) => { requestCompleted = request.complete; response.send = send; @@ -133,18 +157,26 @@ export default (tests) => { }, }) ) - .use((error, request, response, next) => { - expressError = error; - responseStatusCode = response.statusCode; - - // Sending a response here prevents the default Express error handler - // from running, which would undesirably (in this case) display the - // error in the console. - if (response.headersSent) next(error); - else response.send(); - }); + .use( + /** + * @param {Error} error + * @param {import("express").Request} request + * @param {import("express").Response} response + * @param {import("express").NextFunction} next + */ + (error, request, response, next) => { + expressError = error; + responseStatusCode = response.statusCode; + + // Sending a response here prevents the default Express error handler + // from running, which would undesirably (in this case) display the + // error in the console. + if (response.headersSent) next(error); + else response.send(); + } + ); - const { port, close } = await listen(app); + const { port, close } = await listen(createServer(app)); try { const body = new FormData(); @@ -181,6 +213,7 @@ export default (tests) => { .use((request, response, next) => { const { send } = response; + // @ts-ignore Todo: Find a less hacky way. response.send = (...args) => { requestCompleted = request.complete; response.send = send; @@ -193,17 +226,25 @@ export default (tests) => { .use(() => { throw error; }) - .use((error, request, response, next) => { - expressError = error; - - // Sending a response here prevents the default Express error handler - // from running, which would undesirably (in this case) display the - // error in the console. - if (response.headersSent) next(error); - else response.send(); - }); + .use( + /** + * @param {Error} error + * @param {import("express").Request} request + * @param {import("express").Response} response + * @param {import("express").NextFunction} next + */ + (error, request, response, next) => { + expressError = error; + + // Sending a response here prevents the default Express error handler + // from running, which would undesirably (in this case) display the + // error in the console. + if (response.headersSent) next(error); + else response.send(); + } + ); - const { port, close } = await listen(app); + const { port, close } = await listen(createServer(app)); try { const body = new FormData(); diff --git a/graphqlUploadKoa.js b/graphqlUploadKoa.js index 78017ae..654fc67 100644 --- a/graphqlUploadKoa.js +++ b/graphqlUploadKoa.js @@ -1,28 +1,22 @@ +// @ts-check + "use strict"; const defaultProcessRequest = require("./processRequest.js"); /** - * Creates [Koa](https://koajs.com) middleware that processes + * Creates [Koa](https://koajs.com) middleware that processes incoming * [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec) - * using [`processRequest`]{@link processRequest}, ignoring non-multipart - * requests. It sets the request body to be - * [similar to a conventional GraphQL POST request]{@link GraphQLOperation} for + * using {@linkcode processRequest}, ignoring non multipart requests. It sets + * the request `body` to be similar to a conventional GraphQL POST request for * following GraphQL middleware to consume. - * @kind function - * @name graphqlUploadKoa - * @param {ProcessRequestOptions} options Middleware options. Any [`ProcessRequestOptions`]{@link ProcessRequestOptions} can be used. - * @param {ProcessRequestFunction} [options.processRequest=processRequest] Used to process [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec). - * @returns {Function} Koa middleware. - * @example How to `import`. - * ```js - * import graphqlUploadKoa from "graphql-upload/graphqlUploadKoa.js"; - * ``` - * @example How to `require`. - * ```js - * const graphqlUploadKoa = require("graphql-upload/graphqlUploadKoa.js"); - * ``` - * @example Basic [`graphql-api-koa`](https://npm.im/graphql-api-koa) setup. + * @param {import("./processRequest.js").ProcessRequestOptions & { + * processRequest?: import("./processRequest.js").ProcessRequestFunction + * }} options Options. + * @returns Koa middleware. + * @example + * Basic [`graphql-api-koa`](https://npm.im/graphql-api-koa) setup: + * * ```js * const Koa = require("koa"); * const bodyParser = require("koa-bodyparser"); @@ -38,16 +32,26 @@ const defaultProcessRequest = require("./processRequest.js"); * .listen(3000); * ``` */ -module.exports = function graphqlUploadKoa({ +function graphqlUploadKoa({ processRequest = defaultProcessRequest, ...processRequestOptions } = {}) { - return async function graphqlUploadKoaMiddleware(ctx, next) { + /** + * [Koa](https://koajs.com) middleware that processes incoming + * [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec) + * using {@linkcode processRequest}, ignoring non multipart requests. It sets + * the request `body` to be similar to a conventional GraphQL POST request for + * following GraphQL middleware to consume. + * @param {import("koa").Context} ctx + * @param {() => Promise} next + */ + async function graphqlUploadKoaMiddleware(ctx, next) { if (!ctx.request.is("multipart/form-data")) return next(); - const finished = new Promise((resolve) => ctx.req.on("end", resolve)); + const requestEnd = new Promise((resolve) => ctx.req.on("end", resolve)); try { + // @ts-ignore This is conventional. ctx.request.body = await processRequest( ctx.req, ctx.res, @@ -55,7 +59,11 @@ module.exports = function graphqlUploadKoa({ ); await next(); } finally { - await finished; + await requestEnd; } - }; -}; + } + + return graphqlUploadKoaMiddleware; +} + +module.exports = graphqlUploadKoa; diff --git a/graphqlUploadKoa.test.mjs b/graphqlUploadKoa.test.mjs index ccc467b..1d6f694 100644 --- a/graphqlUploadKoa.test.mjs +++ b/graphqlUploadKoa.test.mjs @@ -1,4 +1,7 @@ +// @ts-check + import { deepStrictEqual, ok, strictEqual } from "assert"; +import { createServer } from "http"; import Koa from "koa"; import fetch, { File, FormData } from "node-fetch"; @@ -6,19 +9,24 @@ import graphqlUploadKoa from "./graphqlUploadKoa.js"; import processRequest from "./processRequest.js"; import listen from "./test/listen.mjs"; +/** + * Adds `graphqlUploadKoa` tests. + * @param {import("test-director").default} tests Test director. + */ export default (tests) => { - tests.add("`graphqlUploadKoa` with a non-multipart request.", async () => { + tests.add("`graphqlUploadKoa` with a non multipart request.", async () => { let processRequestRan = false; const app = new Koa().use( graphqlUploadKoa({ + /** @type {any} */ async processRequest() { processRequestRan = true; }, }) ); - const { port, close } = await listen(app); + const { port, close } = await listen(createServer(app.callback())); try { await fetch(`http://localhost:${port}`, { method: "POST" }); @@ -29,14 +37,23 @@ export default (tests) => { }); tests.add("`graphqlUploadKoa` with a multipart request.", async () => { + /** + * @type {{ + * variables: { + * file: import("./Upload.js"), + * }, + * } | undefined} + */ let ctxRequestBody; const app = new Koa().use(graphqlUploadKoa()).use(async (ctx, next) => { - ctxRequestBody = ctx.request.body; + ctxRequestBody = + // @ts-ignore By convention this should be present. + ctx.request.body; await next(); }); - const { port, close } = await listen(app); + const { port, close } = await listen(createServer(app.callback())); try { const body = new FormData(); @@ -59,6 +76,14 @@ export default (tests) => { "`graphqlUploadKoa` with a multipart request and option `processRequest`.", async () => { let processRequestRan = false; + + /** + * @type {{ + * variables: { + * file: import("./Upload.js"), + * }, + * } | undefined} + */ let ctxRequestBody; const app = new Koa() @@ -71,11 +96,13 @@ export default (tests) => { }) ) .use(async (ctx, next) => { - ctxRequestBody = ctx.request.body; + ctxRequestBody = + // @ts-ignore By convention this should be present. + ctx.request.body; await next(); }); - const { port, close } = await listen(app); + const { port, close } = await listen(createServer(app.callback())); try { const body = new FormData(); @@ -126,7 +153,7 @@ export default (tests) => { }) ); - const { port, close } = await listen(app); + const { port, close } = await listen(createServer(app.callback())); try { const body = new FormData(); @@ -174,7 +201,7 @@ export default (tests) => { throw error; }); - const { port, close } = await listen(app); + const { port, close } = await listen(createServer(app.callback())); try { const body = new FormData(); diff --git a/ignoreStream.js b/ignoreStream.js index ec67e62..1454925 100644 --- a/ignoreStream.js +++ b/ignoreStream.js @@ -1,16 +1,17 @@ +// @ts-check + "use strict"; /** * Safely ignores a Node.js readable stream. - * @kind function - * @name ignoreStream - * @param {ReadableStream} stream Node.js readable stream. - * @ignore + * @param {import("stream").Readable} stream Node.js readable stream. */ -module.exports = function ignoreStream(stream) { +function ignoreStream(stream) { // Prevent an unhandled error from crashing the process. stream.on("error", () => {}); // Waste the stream. stream.resume(); -}; +} + +module.exports = ignoreStream; diff --git a/ignoreStream.test.mjs b/ignoreStream.test.mjs index 31aa3d0..1837445 100644 --- a/ignoreStream.test.mjs +++ b/ignoreStream.test.mjs @@ -1,8 +1,14 @@ +// @ts-check + import { doesNotThrow, strictEqual } from "assert"; import ignoreStream from "./ignoreStream.js"; import CountReadableStream from "./test/CountReadableStream.mjs"; +/** + * Adds `ignoreStream` tests. + * @param {import("test-director").default} tests Test director. + */ export default (tests) => { tests.add("`ignoreStream` ignores errors.", () => { doesNotThrow(() => { diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..edb002b --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "nodenext", + "noEmit": true, + "strict": true + }, + "typeAcquisition": { + "enable": false + } +} diff --git a/package.json b/package.json index 4a881df..b2634f1 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,11 @@ "graphql": "0.13.1 - 16" }, "dependencies": { + "@types/busboy": "^0.3.2", + "@types/express": "^4.17.13", + "@types/koa": "^2.13.4", + "@types/node": "*", + "@types/object-path": "^0.11.1", "busboy": "^0.3.1", "fs-capacitor": "^6.2.0", "http-errors": "^2.0.0", @@ -63,20 +68,19 @@ "express": "^4.18.0", "form-data-encoder": "^1.7.2", "graphql": "^16.4.0", - "jsdoc-md": "^11.0.2", "koa": "^2.13.4", "node-abort-controller": "^3.0.1", "node-fetch": "^3.2.3", "prettier": "^2.6.2", - "test-director": "^8.0.2" + "test-director": "^8.0.2", + "typescript": "^4.7.0-dev.20220505" }, "scripts": { - "docs-update": "jsdoc-md", - "docs-check": "jsdoc-md -c", "eslint": "eslint .", "prettier": "prettier -c .", + "types": "tsc -p jsconfig.json", "tests": "coverage-node --unhandled-rejections=throw test.mjs", - "test": "npm run eslint && npm run prettier && npm run docs-check && npm run tests", + "test": "npm run eslint && npm run prettier && npm run types && npm run tests", "prepublishOnly": "npm test" } } diff --git a/processRequest.js b/processRequest.js index 8c32359..c6337b7 100644 --- a/processRequest.js +++ b/processRequest.js @@ -1,3 +1,5 @@ +// @ts-check + "use strict"; const Busboy = require("busboy"); @@ -8,32 +10,23 @@ const GRAPHQL_MULTIPART_REQUEST_SPEC_URL = require("./GRAPHQL_MULTIPART_REQUEST_ const ignoreStream = require("./ignoreStream.js"); const Upload = require("./Upload.js"); +/** @typedef {import("./GraphQLUpload.js")} GraphQLUpload */ +/** @typedef {import("./graphqlUploadExpress.js")} graphqlUploadExpress */ +/** @typedef {import("./graphqlUploadKoa.js")} graphqlUploadKoa */ + /** - * Processes a + * Processes an incoming * [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec). - * It parses the `operations` and `map` fields to create an - * [`Upload`]{@link Upload} instance for each expected file upload, placing - * references wherever the file is expected in the - * [GraphQL operation]{@link GraphQLOperation} for the - * [`Upload`]{@link GraphQLUpload} scalar to derive it’s value. Errors are - * created with [`http-errors`](https://npm.im/http-errors) to assist in sending - * responses with appropriate HTTP status codes. Used in - * [`graphqlUploadExpress`]{@link graphqlUploadExpress} and - * [`graphqlUploadKoa`]{@link graphqlUploadKoa} and can be used to create custom - * middleware. - * @kind function - * @name processRequest + * It parses the `operations` and `map` fields to create an {@linkcode Upload} + * instance for each expected file upload, placing references wherever the file + * is expected in the GraphQL operation for the {@linkcode GraphQLUpload} scalar + * to derive it’s value. Errors are created with + * [`http-errors`](https://npm.im/http-errors) to assist in sending responses + * with appropriate HTTP status codes. Used to create custom middleware and is + * used by {@linkcode graphqlUploadExpress} and {@linkcode graphqlUploadKoa}. * @type {ProcessRequestFunction} - * @example How to `import`. - * ```js - * import processRequest from "graphql-upload/processRequest.js"; - * ``` - * @example How to `require`. - * ```js - * const processRequest = require("graphql-upload/processRequest.js"); - * ``` */ -module.exports = function processRequest( +function processRequest( request, response, { @@ -43,14 +36,34 @@ module.exports = function processRequest( } = {} ) { return new Promise((resolve, reject) => { + /** @type {boolean} */ let released; + + /** @type {Error} */ let exitError; + + /** @type {import("stream").Readable} */ let lastFileStream; + + /** + * @type {{ [key: string]: unknown } | Array< + * { [key: string]: unknown } + * >} + */ let operations; + + /** + * @type {import("object-path").ObjectPathBound< + * { [key: string]: unknown } | Array<{ [key: string]: unknown }> + * >} + */ let operationsPath; + + /** @type {Map} */ let map; const parser = new Busboy({ + // @ts-ignore This is about to change with `busboy` v1 types. headers: request.headers, limits: { fieldSize: maxFieldSize, @@ -62,10 +75,7 @@ module.exports = function processRequest( /** * Exits request processing with an error. Successive calls have no effect. - * @kind function - * @name processRequest~exit - * @param {object} error Error instance. - * @ignore + * @param {Error} error Error instance. */ const exit = (error) => { // None of the tested scenarios cause multiple calls of this function, but @@ -244,7 +254,9 @@ module.exports = function processRequest( return; } + /** @type {Error} */ let fileError; + const capacitor = new WriteStream(); capacitor.on("error", () => { @@ -267,6 +279,7 @@ module.exports = function processRequest( capacitor.destroy(fileError); }); + /** @type {FileUpload} */ const file = { filename, mimetype, @@ -276,9 +289,14 @@ module.exports = function processRequest( if (error) throw error; return capacitor.createReadStream(options); }, + capacitor, }; - Object.defineProperty(file, "capacitor", { value: capacitor }); + Object.defineProperty(file, "capacitor", { + enumerable: false, + configurable: false, + writable: false, + }); stream.pipe(capacitor); upload.resolve(file); @@ -337,42 +355,81 @@ module.exports = function processRequest( request.pipe(parser); }); -}; +} + +module.exports = processRequest; + +/** + * File upload details that are only available after the file’s field in the + * [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec) + * has begun streaming in. + * @typedef {object} FileUpload + * @prop {string} filename File name. + * @prop {string} mimetype File MIME type. Provided by the client and can’t be + * trusted. + * @prop {string} encoding File stream transfer encoding. + * @prop {import("fs-capacitor").WriteStream} capacitor A private implementation + * detail that shouldn’t be used outside + * [`graphql-upload`](https://npm.im/graphql-upload). + * @prop {FileUploadCreateReadStream} createReadStream Creates a + * [Node.js readable stream](https://nodejs.org/api/stream.html#readable-streams) + * of the file’s contents, for processing and storage. + */ + +/** + * Creates a + * [Node.js readable stream](https://nodejs.org/api/stream.html#readable-streams) + * of an {@link FileUpload uploading file’s} contents, for processing and + * storage. Multiple calls create independent streams. Throws if called after + * all resolvers have resolved, or after an error has interrupted the request. + * @callback FileUploadCreateReadStream + * @param {FileUploadCreateReadStreamOptions} [options] Options. + * @returns {import("stream").Readable} + * [Node.js readable stream](https://nodejs.org/api/stream.html#readable-streams) + * of the file’s contents. + * @see [Node.js `Readable` stream constructor docs](https://nodejs.org/api/stream.html#new-streamreadableoptions). + * @see [Node.js stream backpressure guide](https://nodejs.org/en/docs/guides/backpressuring-in-streams). + */ /** - * A GraphQL operation object in a shape that can be consumed and executed by - * most GraphQL servers. - * @kind typedef - * @name GraphQLOperation - * @type {object} - * @prop {string} query GraphQL document containing queries and fragments. - * @prop {string|null} [operationName] GraphQL document operation name to execute. - * @prop {object|null} [variables] GraphQL document operation variables and values map. - * @see [GraphQL over HTTP spec](https://github.com/graphql/graphql-over-http). - * @see [Apollo Server POST requests](https://www.apollographql.com/docs/apollo-server/requests/#post-requests). + * {@linkcode FileUploadCreateReadStream} options. + * @typedef {object} FileUploadCreateReadStreamOptions + * @prop {string} [options.encoding] Specify an encoding for the + * [`data`](https://nodejs.org/api/stream.html#event-data) chunks to be + * strings (without splitting multi-byte characters across chunks) instead of + * Node.js [`Buffer`](https://nodejs.org/api/buffer.html#buffer) instances. + * Supported values depend on the + * [`Buffer` implementation](https://github.com/nodejs/node/blob/v18.1.0/lib/buffer.js#L590-L680) + * and include `utf8`, `ucs2`, `utf16le`, `latin1`, `ascii`, `base64`, + * `base64url`, or `hex`. Defaults to `utf8`. + * @prop {number} [options.highWaterMark] Maximum number of bytes to store in + * the internal buffer before ceasing to read from the underlying resource. + * Defaults to `16384`. */ /** - * Processes a + * Processes an incoming * [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec). - * @kind typedef - * @name ProcessRequestFunction - * @type {Function} - * @param {IncomingMessage} request [Node.js HTTP server request instance](https://nodejs.org/api/http.html#http_class_http_incomingmessage). - * @param {ServerResponse} response [Node.js HTTP server response instance](https://nodejs.org/api/http.html#http_class_http_serverresponse). - * @param {ProcessRequestOptions} [options] Options for processing the request. - * @returns {Promise>} GraphQL operation or batch of operations for a GraphQL server to consume (usually as the request body). - * @see [`processRequest`]{@link processRequest}. + * @callback ProcessRequestFunction + * @param {import("http").IncomingMessage} request + * [Node.js HTTP server request instance](https://nodejs.org/api/http.html#http_class_http_incomingmessage). + * @param {import("http").ServerResponse} response + * [Node.js HTTP server response instance](https://nodejs.org/api/http.html#http_class_http_serverresponse). + * @param {ProcessRequestOptions} [options] Options. + * @returns {Promise< + * { [key: string]: unknown } | Array<{ [key: string]: unknown }> + * >} GraphQL operation or batch of operations for a GraphQL server to consume + * (usually as the request body). A GraphQL operation typically has the + * properties `query` and `variables`. */ /** - * Options for processing a - * [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec); - * mostly relating to security, performance and limits. - * @kind typedef - * @name ProcessRequestOptions - * @type {object} - * @prop {number} [maxFieldSize=1000000] Maximum allowed non-file multipart form field size in bytes; enough for your queries. - * @prop {number} [maxFileSize=Infinity] Maximum allowed file size in bytes. - * @prop {number} [maxFiles=Infinity] Maximum allowed number of files. + * {@linkcode ProcessRequestFunction} options. + * @typedef {object} ProcessRequestOptions + * @prop {number} [maxFieldSize] Maximum allowed non file multipart form field + * size in bytes; enough for your queries. Defaults to `1000000` (1 MB). + * @prop {number} [maxFileSize] Maximum allowed file size in bytes. Defaults to + * `Infinity`. + * @prop {number} [maxFiles] Maximum allowed number of files. Defaults to + * `Infinity`. */ diff --git a/processRequest.test.mjs b/processRequest.test.mjs index 02ba1fa..d404404 100644 --- a/processRequest.test.mjs +++ b/processRequest.test.mjs @@ -1,3 +1,5 @@ +// @ts-check + import { deepStrictEqual, notStrictEqual, @@ -12,10 +14,15 @@ import fetch, { File, FormData } from "node-fetch"; import processRequest from "./processRequest.js"; import abortingMultipartRequest from "./test/abortingMultipartRequest.mjs"; +import Deferred from "./test/Deferred.mjs"; import listen from "./test/listen.mjs"; import streamToString from "./test/streamToString.mjs"; import Upload from "./Upload.js"; +/** + * Adds `processRequest` tests. + * @param {import("test-director").default} tests Test director. + */ export default (tests) => { tests.add("`processRequest` with no files.", async () => { let serverError; @@ -54,7 +61,15 @@ export default (tests) => { const server = createServer(async (request, response) => { try { - const operation = await processRequest(request, response); + const operation = + /** + * @type {{ + * variables: { + * file: import("./Upload.js"), + * }, + * }} + */ + (await processRequest(request, response)); ok(operation.variables.file instanceof Upload); @@ -105,7 +120,15 @@ export default (tests) => { const server = createServer(async (request, response) => { try { - const operation = await processRequest(request, response); + const operation = + /** + * @type {{ + * variables: { + * file: import("./Upload.js"), + * }, + * }} + */ + (await processRequest(request, response)); ok(operation.variables.file instanceof Upload); @@ -159,7 +182,15 @@ export default (tests) => { const server = createServer(async (request, response) => { try { - const operations = await processRequest(request, response); + const operations = + /** + * @type {Array<{ + * variables: { + * file: import("./Upload.js"), + * }, + * }>} + */ + (await processRequest(request, response)); ok(operations[0].variables.file instanceof Upload); @@ -225,7 +256,15 @@ export default (tests) => { const server = createServer(async (request, response) => { try { - const operation = await processRequest(request, response); + const operation = + /** + * @type {{ + * variables: { + * files: Array, + * }, + * }} + */ + (await processRequest(request, response)); ok(operation.variables.files[0] instanceof Upload); ok(operation.variables.files[1] instanceof Upload); @@ -290,7 +329,16 @@ export default (tests) => { const server = createServer(async (request, response) => { try { - const operation = await processRequest(request, response); + const operation = + /** + * @type {{ + * variables: { + * fileA: import("./Upload.js"), + * fileB: import("./Upload.js"), + * }, + * }} + */ + (await processRequest(request, response)); ok(operation.variables.fileB instanceof Upload); @@ -336,7 +384,15 @@ export default (tests) => { const server = createServer(async (request, response) => { try { - const operation = await processRequest(request, response); + const operation = + /** + * @type {{ + * variables: { + * file: import("./Upload.js"), + * }, + * }} + */ + (await processRequest(request, response)); ok(operation.variables.file instanceof Upload); @@ -386,7 +442,15 @@ export default (tests) => { const server = createServer(async (request, response) => { try { - const operation = await processRequest(request, response); + const operation = + /** + * @type {{ + * variables: { + * file: import("./Upload.js"), + * }, + * }} + */ + (await processRequest(request, response)); ok(operation.variables.file instanceof Upload); await rejects(operation.variables.file.promise, { @@ -474,9 +538,15 @@ export default (tests) => { const server = createServer(async (request, response) => { try { - const operation = await processRequest(request, response, { - maxFiles: 2, - }); + const operation = + /** + * @type {{ + * variables: { + * files: Array, + * }, + * }} + */ + (await processRequest(request, response, { maxFiles: 2 })); ok(operation.variables.files[0] instanceof Upload); @@ -541,9 +611,15 @@ export default (tests) => { const server = createServer(async (request, response) => { try { - const operation = await processRequest(request, response, { - maxFileSize: 1, - }); + const operation = + /** + * @type {{ + * variables: { + * files: Array, + * }, + * }} + */ + (await processRequest(request, response, { maxFileSize: 1 })); ok(operation.variables.files[0] instanceof Upload); @@ -653,23 +729,27 @@ export default (tests) => { // request part way through, the server request handler must be manually // awaited or else the test will resolve and the process will exit before // it’s done. - let resolveDone; - const done = new Promise((resolve) => { - resolveDone = resolve; - }); + const done = new Deferred(); // The request must be aborted after it has been received by the server // request handler, or else Node.js won’t run the handler. - let resolveRequestReceived; - const requestReceived = new Promise((resolve) => { - resolveRequestReceived = resolve; - }); + const requestReceived = new Deferred(); const server = createServer(async (request, response) => { try { - resolveRequestReceived(); - - const operation = await processRequest(request, response); + requestReceived.resolve(); + + const operation = + /** + * @type {{ + * variables: { + * fileA: import("./Upload.js"), + * fileB: import("./Upload.js"), + * fileC: import("./Upload.js"), + * }, + * }} + */ + (await processRequest(request, response)); const testUploadA = async () => { ok(operation.variables.fileA instanceof Upload); @@ -728,7 +808,7 @@ export default (tests) => { serverError = error; } finally { response.end(); - resolveDone(); + done.resolve(); } }); @@ -772,10 +852,10 @@ export default (tests) => { `http://localhost:${port}`, formData, abortMarker, - requestReceived + requestReceived.promise ); - await done; + await done.promise; if (serverError) throw serverError; } finally { @@ -794,23 +874,27 @@ export default (tests) => { // request part way through, the server request handler must be manually // awaited or else the test will resolve and the process will exit before // it’s done. - let resolveDone; - const done = new Promise((resolve) => { - resolveDone = resolve; - }); + const done = new Deferred(); // The request must be aborted after it has been received by the server // request handler, or else Node.js won’t run the handler. - let resolveRequestReceived; - const requestReceived = new Promise((resolve) => { - resolveRequestReceived = resolve; - }); + const requestReceived = new Deferred(); const server = createServer(async (request, response) => { try { - resolveRequestReceived(); - - const operation = await processRequest(request, response); + requestReceived.resolve(); + + const operation = + /** + * @type {{ + * variables: { + * fileA: import("./Upload.js"), + * fileB: import("./Upload.js"), + * fileC: import("./Upload.js"), + * }, + * }} + */ + (await processRequest(request, response)); // Wait for the request parsing to finish. await new Promise((resolve) => { @@ -868,7 +952,7 @@ export default (tests) => { serverError = error; } finally { response.end(); - resolveDone(); + done.resolve(); } }); @@ -912,10 +996,10 @@ export default (tests) => { `http://localhost:${port}`, formData, abortMarker, - requestReceived + requestReceived.promise ); - await done; + await done.promise; if (serverError) throw serverError; } finally { diff --git a/readme.md b/readme.md index 3ba677b..cafa567 100644 --- a/readme.md +++ b/readme.md @@ -4,7 +4,7 @@ [![npm version](https://badgen.net/npm/v/graphql-upload)](https://npm.im/graphql-upload) [![CI status](https://github.com/jaydenseric/graphql-upload/workflows/CI/badge.svg)](https://github.com/jaydenseric/graphql-upload/actions) -Middleware and an [`Upload`](#class-graphqlupload) scalar to add support for [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec) (file uploads via queries and mutations) to various Node.js GraphQL servers. +Middleware and an [`Upload`](./GraphQLUpload.js) scalar to add support for [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec) (file uploads via queries and mutations) to various Node.js GraphQL servers. ## Installation @@ -16,367 +16,44 @@ To install [`graphql-upload`](https://npm.im/graphql-upload) and the [`graphql`] npm install graphql-upload graphql ``` -Use the [`graphqlUploadKoa`](#function-graphqluploadkoa) or [`graphqlUploadExpress`](#function-graphqluploadexpress) middleware just before GraphQL middleware. Alternatively, use [`processRequest`](#function-processrequest) to create custom middleware. +Use the [`graphqlUploadKoa`](./graphqlUploadKoa.js) or [`graphqlUploadExpress`](./graphqlUploadExpress.js) middleware just before GraphQL middleware. Alternatively, use [`processRequest`](./processRequest.js) to create custom middleware. -A schema built with separate SDL and resolvers (e.g. using [`makeExecutableSchema`](https://www.graphql-tools.com/docs/api/modules/schema#makeexecutableschema) from [`@graphql-tools/schema`](https://npm.im/@graphql-tools/schema)) requires the [`Upload`](#class-graphqlupload) scalar to be setup. +A schema built with separate SDL and resolvers (e.g. using [`makeExecutableSchema`](https://www.graphql-tools.com/docs/api/modules/schema#makeexecutableschema) from [`@graphql-tools/schema`](https://npm.im/@graphql-tools/schema)) requires the [`Upload`](./GraphQLUpload.js) scalar to be setup. ## Usage -[Clients implementing the GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec#client) upload files as [`Upload`](#class-graphqlupload) scalar query or mutation variables. Their resolver values are promises that resolve [file upload details](#type-fileupload) for processing and storage. Files are typically streamed into cloud storage but may also be stored in the filesystem. +[Clients implementing the GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec#client) upload files as [`Upload`](./GraphQLUpload.js) scalar query or mutation variables. Their resolver values are promises that resolve file upload details for processing and storage. Files are typically streamed into cloud storage but may also be stored in the filesystem. See the [example API and client](https://github.com/jaydenseric/apollo-upload-examples). ### Tips -- The process must have both read and write access to the directory identified by [`os.tmpdir()`](https://nodejs.org/api/os.html#os_os_tmpdir). +- The process must have both read and write access to the directory identified by [`os.tmpdir()`](https://nodejs.org/api/os.html#ostmpdir). - The device requires sufficient disk space to buffer the expected number of concurrent upload requests. - Promisify and await file upload streams in resolvers or the server will send a response to the client before uploads are complete, causing a disconnect. - Handle file upload promise rejection and stream errors; uploads sometimes fail due to network connectivity issues or impatient users disconnecting. -- Process multiple uploads asynchronously with [`Promise.all`](https://developer.mozilla.org/en-US/docs/web/javascript/reference/global_objects/promise/all) or a more flexible solution such as [`Promise.allSettled`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) where an error in one does not reject them all. -- Only use [`createReadStream()`](#type-fileupload) _before_ the resolver returns; late calls (e.g. in an unawaited async function or callback) throw an error. Existing streams can still be used after a response is sent, although there are few valid reasons for not awaiting their completion. -- Use [`stream.destroy()`](https://nodejs.org/api/stream.html#stream_readable_destroy_error) when an incomplete stream is no longer needed, or temporary files may not get cleaned up. +- Process multiple uploads asynchronously with [`Promise.all`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) or a more flexible solution such as [`Promise.allSettled`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) where an error in one does not reject them all. +- Only use the function `createReadStream` _before_ the resolver returns; late calls (e.g. in an unawaited async function or callback) throw an error. Existing streams can still be used after a response is sent, although there are few valid reasons for not awaiting their completion. +- Use [`stream.destroy()`](https://nodejs.org/api/stream.html#readabledestroyerror) when an incomplete stream is no longer needed, or temporary files may not get cleaned up. ## Architecture The [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec) allows a file to be used for multiple query or mutation variables (file deduplication), and for variables to be used in multiple places. GraphQL resolvers need to be able to manage independent file streams. As resolvers are executed asynchronously, it’s possible they will try to process files in a different order than received in the multipart request. -[`busboy`](https://npm.im/busboy) parses multipart request streams. Once the `operations` and `map` fields have been parsed, [`Upload`](#class-graphqlupload) scalar values in the GraphQL operations are populated with promises, and the operations are passed down the middleware chain to GraphQL resolvers. +[`busboy`](https://npm.im/busboy) parses multipart request streams. Once the `operations` and `map` fields have been parsed, [`Upload`](./GraphQLUpload.js) scalar values in the GraphQL operations are populated with promises, and the operations are passed down the middleware chain to GraphQL resolvers. -[`fs-capacitor`](https://npm.im/fs-capacitor) is used to buffer file uploads to the filesystem and coordinate simultaneous reading and writing. As soon as a file upload’s contents begins streaming, its data begins buffering to the filesystem and its associated promise resolves. GraphQL resolvers can then create new streams from the buffer by calling [`createReadStream()`](#type-fileupload). The buffer is destroyed once all streams have ended or closed and the server has responded to the request. Any remaining buffer files will be cleaned when the process exits. +[`fs-capacitor`](https://npm.im/fs-capacitor) is used to buffer file uploads to the filesystem and coordinate simultaneous reading and writing. As soon as a file upload’s contents begins streaming, its data begins buffering to the filesystem and its associated promise resolves. GraphQL resolvers can then create new streams from the buffer by calling the function `createReadStream`. The buffer is destroyed once all streams have ended or closed and the server has responded to the request. Any remaining buffer files will be cleaned when the process exits. ## Requirements - [Node.js](https://nodejs.org): `^14.17.0 || ^16.0.0 || >= 18.0.0` -## API +## Exports -- [class GraphQLUpload](#class-graphqlupload) -- [class Upload](#class-upload) - - [Upload instance method reject](#upload-instance-method-reject) - - [Upload instance method resolve](#upload-instance-method-resolve) - - [Upload instance property file](#upload-instance-property-file) - - [Upload instance property promise](#upload-instance-property-promise) -- [function graphqlUploadExpress](#function-graphqluploadexpress) -- [function graphqlUploadKoa](#function-graphqluploadkoa) -- [function processRequest](#function-processrequest) -- [type FileUpload](#type-fileupload) -- [type FileUploadCreateReadStream](#type-fileuploadcreatereadstream) -- [type GraphQLOperation](#type-graphqloperation) -- [type ProcessRequestFunction](#type-processrequestfunction) -- [type ProcessRequestOptions](#type-processrequestoptions) +These ECMAScript modules are published to [npm](https://npmjs.com) and exported via the [`package.json`](./package.json) `exports` field: -### class GraphQLUpload - -A GraphQL `Upload` scalar that can be used in a [`GraphQLSchema`](https://graphql.org/graphql-js/type/#graphqlschema). It’s value in resolvers is a promise that resolves [file upload details](#type-fileupload) for processing and storage. - -#### Examples - -_How to `import`._ - -> ```js -> import GraphQLUpload from "graphql-upload/GraphQLUpload.js"; -> ``` - -_How to `require`._ - -> ```js -> const GraphQLUpload = require("graphql-upload/GraphQLUpload.js"); -> ``` - -_A schema built using [`makeExecutableSchema`](https://www.graphql-tools.com/docs/api/modules/schema#makeexecutableschema) from [`@graphql-tools/schema`](https://npm.im/@graphql-tools/schema)._ - -> ```js -> const { makeExecutableSchema } = require("@graphql-tools/schema"); -> const GraphQLUpload = require("graphql-upload/GraphQLUpload.js"); -> -> const schema = makeExecutableSchema({ -> typeDefs: /* GraphQL */ ` -> scalar Upload -> `, -> resolvers: { -> Upload: GraphQLUpload, -> }, -> }); -> ``` - -_A manually constructed schema with an image upload mutation._ - -> ```js -> const { -> GraphQLSchema, -> GraphQLObjectType, -> GraphQLBoolean, -> } = require("graphql"); -> const GraphQLUpload = require("graphql-upload/GraphQLUpload.js"); -> -> const schema = new GraphQLSchema({ -> mutation: new GraphQLObjectType({ -> name: "Mutation", -> fields: { -> uploadImage: { -> description: "Uploads an image.", -> type: GraphQLBoolean, -> args: { -> image: { -> description: "Image file.", -> type: GraphQLUpload, -> }, -> }, -> async resolve(parent, { image }) { -> const { filename, mimetype, createReadStream } = await image; -> const stream = createReadStream(); -> // Promisify the stream and store the file, then… -> return true; -> }, -> }, -> }, -> }), -> }); -> ``` - ---- - -### class Upload - -A file expected to be uploaded as it has been declared in the `map` field of a [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec). The [`processRequest`](#function-processrequest) function places references to an instance of this class wherever the file is expected in the [GraphQL operation](#type-graphqloperation). The [`Upload`](#class-graphqlupload) scalar derives it’s value from the [`promise`](#upload-instance-property-promise) property. - -#### Examples - -_How to `import`._ - -> ```js -> import Upload from "graphql-upload/Upload.js"; -> ``` - -_How to `require`._ - -> ```js -> const Upload = require("graphql-upload/Upload.js"); -> ``` - -#### Upload instance method reject - -Rejects the upload promise with an error. This should only be utilized by [`processRequest`](#function-processrequest). - -| Parameter | Type | Description | -| :-------- | :----- | :-------------- | -| `error` | object | Error instance. | - -#### Upload instance method resolve - -Resolves the upload promise with the file upload details. This should only be utilized by [`processRequest`](#function-processrequest). - -| Parameter | Type | Description | -| :-------- | :----------------------------- | :------------------- | -| `file` | [FileUpload](#type-fileupload) | File upload details. | - -#### Upload instance property file - -The file upload details, available when the [upload promise](#upload-instance-property-promise) resolves. This should only be utilized by [`processRequest`](#function-processrequest). - -**Type:** `undefined` | [FileUpload](#type-fileupload) - -#### Upload instance property promise - -Promise that resolves file upload details. This should only be utilized by [`GraphQLUpload`](#class-graphqlupload). - -**Type:** Promise<[FileUpload](#type-fileupload)> - ---- - -### function graphqlUploadExpress - -Creates [Express](https://expressjs.com) middleware that processes [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec) using [`processRequest`](#function-processrequest), ignoring non-multipart requests. It sets the request body to be [similar to a conventional GraphQL POST request](#type-graphqloperation) for following GraphQL middleware to consume. - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `options` | [ProcessRequestOptions](#type-processrequestoptions) | Middleware options. Any [`ProcessRequestOptions`](#type-processrequestoptions) can be used. | -| `options.processRequest` | [ProcessRequestFunction](#type-processrequestfunction)? = [processRequest](#function-processrequest) | Used to process [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec). | - -**Returns:** Function — Express middleware. - -#### Examples - -_How to `import`._ - -> ```js -> import graphqlUploadExpress from "graphql-upload/graphqlUploadExpress.js"; -> ``` - -_How to `require`._ - -> ```js -> const graphqlUploadExpress = require("graphql-upload/graphqlUploadExpress.js"); -> ``` - -_Basic [`express-graphql`](https://npm.im/express-graphql) setup._ - -> ```js -> const express = require("express"); -> const graphqlHTTP = require("express-graphql"); -> const graphqlUploadExpress = require("graphql-upload/graphqlUploadExpress.js"); -> const schema = require("./schema.js"); -> -> express() -> .use( -> "/graphql", -> graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 10 }), -> graphqlHTTP({ schema }) -> ) -> .listen(3000); -> ``` - ---- - -### function graphqlUploadKoa - -Creates [Koa](https://koajs.com) middleware that processes [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec) using [`processRequest`](#function-processrequest), ignoring non-multipart requests. It sets the request body to be [similar to a conventional GraphQL POST request](#type-graphqloperation) for following GraphQL middleware to consume. - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `options` | [ProcessRequestOptions](#type-processrequestoptions) | Middleware options. Any [`ProcessRequestOptions`](#type-processrequestoptions) can be used. | -| `options.processRequest` | [ProcessRequestFunction](#type-processrequestfunction)? = [processRequest](#function-processrequest) | Used to process [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec). | - -**Returns:** Function — Koa middleware. - -#### Examples - -_How to `import`._ - -> ```js -> import graphqlUploadKoa from "graphql-upload/graphqlUploadKoa.js"; -> ``` - -_How to `require`._ - -> ```js -> const graphqlUploadKoa = require("graphql-upload/graphqlUploadKoa.js"); -> ``` - -_Basic [`graphql-api-koa`](https://npm.im/graphql-api-koa) setup._ - -> ```js -> const Koa = require("koa"); -> const bodyParser = require("koa-bodyparser"); -> const { errorHandler, execute } = require("graphql-api-koa"); -> const graphqlUploadKoa = require("graphql-upload/graphqlUploadKoa.js"); -> const schema = require("./schema.js"); -> -> new Koa() -> .use(errorHandler()) -> .use(bodyParser()) -> .use(graphqlUploadKoa({ maxFileSize: 10000000, maxFiles: 10 })) -> .use(execute({ schema })) -> .listen(3000); -> ``` - ---- - -### function processRequest - -Processes a [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec). It parses the `operations` and `map` fields to create an [`Upload`](#class-upload) instance for each expected file upload, placing references wherever the file is expected in the [GraphQL operation](#type-graphqloperation) for the [`Upload`](#class-graphqlupload) scalar to derive it’s value. Errors are created with [`http-errors`](https://npm.im/http-errors) to assist in sending responses with appropriate HTTP status codes. Used in [`graphqlUploadExpress`](#function-graphqluploadexpress) and [`graphqlUploadKoa`](#function-graphqluploadkoa) and can be used to create custom middleware. - -**Type:** [ProcessRequestFunction](#type-processrequestfunction) - -#### Examples - -_How to `import`._ - -> ```js -> import processRequest from "graphql-upload/processRequest.js"; -> ``` - -_How to `require`._ - -> ```js -> const processRequest = require("graphql-upload/processRequest.js"); -> ``` - ---- - -### type FileUpload - -File upload details that are only available after the file’s field in the [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec) has begun streaming in. - -**Type:** object - -| Property | Type | Description | -| :-- | :-- | :-- | -| `filename` | string | File name. | -| `mimetype` | string | File MIME type. Provided by the client and can’t be trusted. | -| `encoding` | string | File stream transfer encoding. | -| `createReadStream` | [FileUploadCreateReadStream](#type-fileuploadcreatereadstream) | Creates a [Node.js readable stream](https://nodejs.org/api/stream.html#stream_readable_streams) of the file’s contents, for processing and storage. | - ---- - -### type FileUploadCreateReadStream - -Creates a [Node.js readable stream](https://nodejs.org/api/stream.html#stream_readable_streams) of an [uploading file’s](#type-fileupload) contents, for processing and storage. Multiple calls create independent streams. Throws if called after all resolvers have resolved, or after an error has interrupted the request. - -**Type:** Function - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `options` | object? | [`fs-capacitor`](https://npm.im/fs-capacitor) [`ReadStreamOptions`](https://github.com/mike-marcacci/fs-capacitor#readstreamoptions). | -| `options.encoding` | string? = `null` | Specify an encoding for the [`data`](https://nodejs.org/api/stream.html#stream_event_data) chunks to be strings (without splitting multi-byte characters across chunks) instead of Node.js [`Buffer`](https://nodejs.org/api/buffer.html#buffer_buffer) instances. Supported values depend on the [`Buffer` implementation](https://github.com/nodejs/node/blob/v13.7.0/lib/buffer.js#L587-L663) and include `utf8`, `ucs2`, `utf16le`, `latin1`, `ascii`, `base64`, or `hex`. | -| `options.highWaterMark` | number? = `16384` | Maximum number of bytes to store in the internal buffer before ceasing to read from the underlying resource. | - -**Returns:** Readable — [Node.js readable stream](https://nodejs.org/api/stream.html#stream_readable_streams) of the file’s contents. - -#### See - -- [Node.js `Readable` stream constructor docs](https://nodejs.org/api/stream.html#stream_new_stream_readable_options). -- [Node.js stream backpressure guide](https://nodejs.org/en/docs/guides/backpressuring-in-streams). - ---- - -### type GraphQLOperation - -A GraphQL operation object in a shape that can be consumed and executed by most GraphQL servers. - -**Type:** object - -| Property | Type | Description | -| :-- | :-- | :-- | -| `query` | string | GraphQL document containing queries and fragments. | -| `operationName` | string \| `null`? | GraphQL document operation name to execute. | -| `variables` | object \| `null`? | GraphQL document operation variables and values map. | - -#### See - -- [GraphQL over HTTP spec](https://github.com/graphql/graphql-over-http). -- [Apollo Server POST requests](https://www.apollographql.com/docs/apollo-server/requests/#post-requests). - ---- - -### type ProcessRequestFunction - -Processes a [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec). - -**Type:** Function - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `request` | IncomingMessage | [Node.js HTTP server request instance](https://nodejs.org/api/http.html#http_class_http_incomingmessage). | -| `response` | ServerResponse | [Node.js HTTP server response instance](https://nodejs.org/api/http.html#http_class_http_serverresponse). | -| `options` | [ProcessRequestOptions](#type-processrequestoptions)? | Options for processing the request. | - -**Returns:** Promise<[GraphQLOperation](#type-graphqloperation) | Array<[GraphQLOperation](#type-graphqloperation)>> — GraphQL operation or batch of operations for a GraphQL server to consume (usually as the request body). - -#### See - -- [`processRequest`](#function-processrequest). - ---- - -### type ProcessRequestOptions - -Options for processing a [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec); mostly relating to security, performance and limits. - -**Type:** object - -| Property | Type | Description | -| :-- | :-- | :-- | -| `maxFieldSize` | number? = `1000000` | Maximum allowed non-file multipart form field size in bytes; enough for your queries. | -| `maxFileSize` | number? = Infinity | Maximum allowed file size in bytes. | -| `maxFiles` | number? = Infinity | Maximum allowed number of files. | +- [`GraphQLUpload.js`](./GraphQLUpload.js) +- [`graphqlUploadExpress.js`](./graphqlUploadExpress.js) +- [`graphqlUploadKoa.js`](./graphqlUploadKoa.js) +- [`processRequest.js`](./processRequest.js) +- [`Upload.js`](./Upload.js) diff --git a/test.mjs b/test.mjs index d9ba3a9..9f164ad 100644 --- a/test.mjs +++ b/test.mjs @@ -1,3 +1,5 @@ +// @ts-check + import TestDirector from "test-director"; import test_GraphQLUpload from "./GraphQLUpload.test.mjs"; diff --git a/test/CountReadableStream.mjs b/test/CountReadableStream.mjs index 5ab9ded..eb8da51 100644 --- a/test/CountReadableStream.mjs +++ b/test/CountReadableStream.mjs @@ -1,13 +1,13 @@ +// @ts-check + import { Readable } from "stream"; /** * A count readable stream, for testing purposes. - * @kind class - * @name CountReadableStream - * @see [Example counting stream in the Node.js docs](https://nodejs.org/api/stream.html#stream_an_example_counting_stream). - * @ignore + * @see [Example counting stream in the Node.js docs](https://nodejs.org/api/stream.html#an-example-counting-stream). */ export default class CountReadableStream extends Readable { + /** @param {import("stream").ReadableOptions} [options] */ constructor(options) { super(options); this._max = 1000000; diff --git a/test/Deferred.mjs b/test/Deferred.mjs new file mode 100644 index 0000000..fb09acd --- /dev/null +++ b/test/Deferred.mjs @@ -0,0 +1,20 @@ +// @ts-check + +/** + * A deferred promise that can be externally resolved or rejected. + * @template [Resolves=void] What the promise resolves. + */ +export default class Deferred { + constructor() { + /** The promise. */ + this.promise = /** @type {Promise} */ ( + new Promise((resolve, reject) => { + /** Resolves the promise. */ + this.resolve = resolve; + + /** Rejects the promise. */ + this.reject = reject; + }) + ); + } +} diff --git a/test/abortingMultipartRequest.mjs b/test/abortingMultipartRequest.mjs index 276f167..01b330c 100644 --- a/test/abortingMultipartRequest.mjs +++ b/test/abortingMultipartRequest.mjs @@ -1,3 +1,5 @@ +// @ts-check + import { FormDataEncoder } from "form-data-encoder"; import nodeAbortController from "node-abort-controller"; import fetch, { AbortError } from "node-fetch"; @@ -12,14 +14,13 @@ const textDecoder = new TextDecoder(); /** * Sends a multipart request that deliberately aborts after a certain amount of * data has been uploaded to the server, for testing purposes. - * @kind function - * @name abortingMultipartRequest * @param {string} url The request URL. * @param {FormData} formData A `FormData` instance for the request body. - * @param {string} abortMarker A unique character in the request body that marks where to abort the request. - * @param {Promise} requestReceived Resolves once the request has been received by the server request handler. + * @param {string} abortMarker A unique character in the request body that marks + * where to abort the request. + * @param {Promise} requestReceived Resolves once the request has been + * received by the server request handler. * @returns {Promise} Resolves once the request aborts. - * @ignore */ export default async function abortingMultipartRequest( url, diff --git a/test/listen.mjs b/test/listen.mjs index 7e5a67e..6da9e4d 100644 --- a/test/listen.mjs +++ b/test/listen.mjs @@ -1,20 +1,18 @@ +// @ts-check + /** * Starts a Node.js HTTP server. - * @kind function - * @name listen - * @param {object} server Node.js HTTP server. - * @returns {Promise<{port: number, close: Function}>} Resolves the port the server is listening on, and a server close function. - * @ignore + * @param {import("http").Server} server Node.js HTTP server. + * @returns Resolves the port the server is listening on, and a server close + * function. */ -export default function listen(server) { - return new Promise((resolve, reject) => { - server.listen(function (error) { - if (error) reject(error); - else - resolve({ - port: this.address().port, - close: () => this.close(), - }); - }); +export default async function listen(server) { + await new Promise((resolve) => { + server.listen(resolve); }); + + return { + port: /** @type {import("net").AddressInfo} */ (server.address()).port, + close: () => server.close(), + }; } diff --git a/test/streamToString.mjs b/test/streamToString.mjs index d272d65..ac37762 100644 --- a/test/streamToString.mjs +++ b/test/streamToString.mjs @@ -1,10 +1,9 @@ +// @ts-check + /** * Converts a Node.js readable stream to a string. - * @kind function - * @name streamToString - * @param {ReadableStream} stream Node.js readable stream. + * @param {import("stream").Readable} stream Node.js readable stream. * @returns {Promise} Resolves the final string. - * @ignore */ export default function streamToString(stream) { return new Promise((resolve, reject) => {