From 998a6d116e59d9c440f6d7245732974e26b0e3f1 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Mon, 3 Feb 2025 21:41:40 +0800 Subject: [PATCH 1/4] feat: support cjs and esm both by tshy BREAKING CHANGE: drop Node.js < 18.19.0 support part of https://github.com/eggjs/egg/issues/3644 https://github.com/eggjs/egg/issues/5257 --- .eslintignore | 2 +- .eslintrc | 5 +- .github/workflows/nodejs.yml | 2 +- .gitignore | 3 + app.js | 22 -- app/middleware/multipart.js | 17 -- config/config.default.js | 52 ----- index.d.ts | 119 ---------- package.json | 108 +++++---- src/app.ts | 20 ++ .../context.js => src/app/extend/context.ts | 208 ++++++++++++------ src/app/middleware/multipart.ts | 20 ++ .../app/schedule/clean_tmpdir.ts | 23 +- src/config/config.default.ts | 130 +++++++++++ src/index.ts | 2 + src/lib/LimitError.ts | 12 + src/lib/MultipartFileTooLargeError.ts | 14 ++ lib/utils.js => src/lib/utils.ts | 49 +++-- src/typings/index.d.ts | 4 + test/wrong-mode.test.js | 30 --- test/wrong-mode.test.ts | 27 +++ tsconfig.json | 10 + 22 files changed, 503 insertions(+), 376 deletions(-) delete mode 100644 app.js delete mode 100644 app/middleware/multipart.js delete mode 100644 config/config.default.js delete mode 100644 index.d.ts create mode 100644 src/app.ts rename app/extend/context.js => src/app/extend/context.ts (60%) create mode 100644 src/app/middleware/multipart.ts rename app/schedule/clean_tmpdir.js => src/app/schedule/clean_tmpdir.ts (66%) create mode 100644 src/config/config.default.ts create mode 100644 src/index.ts create mode 100644 src/lib/LimitError.ts create mode 100644 src/lib/MultipartFileTooLargeError.ts rename lib/utils.js => src/lib/utils.ts (59%) create mode 100644 src/typings/index.d.ts delete mode 100644 test/wrong-mode.test.js create mode 100644 test/wrong-mode.test.ts create mode 100644 tsconfig.json diff --git a/.eslintignore b/.eslintignore index 0abd3b4..618ef2b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,3 @@ test/fixtures -node_modules coverage +__snapshots__ diff --git a/.eslintrc b/.eslintrc index c799fe5..9bcdb46 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,3 +1,6 @@ { - "extends": "eslint-config-egg" + "extends": [ + "eslint-config-egg/typescript", + "eslint-config-egg/lib/rules/enforce-node-prefix" + ] } diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index f7ce6cb..63d1994 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -12,6 +12,6 @@ jobs: uses: node-modules/github-actions/.github/workflows/node-test.yml@master with: os: 'ubuntu-latest, macos-latest, windows-latest' - version: '16, 18, 20, 22' + version: '18, 20, 22' secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index e113fad..c8b5e66 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ test/fixtures/**/run *-lock.yaml test/fixtures/apps/ts/tsconfig.tsbuildinfo test/fixtures/apps/ts/**/*.d.ts +.tshy* +.eslintcache +dist diff --git a/app.js b/app.js deleted file mode 100644 index 11ad162..0000000 --- a/app.js +++ /dev/null @@ -1,22 +0,0 @@ -'use strict'; - -const { normalizeOptions } = require('./lib/utils'); - -module.exports = class AppBootHook { - constructor(app) { - this.app = app; - } - - configWillLoad() { - this.app.config.multipart = normalizeOptions(this.app.config.multipart); - const options = this.app.config.multipart; - - this.app.coreLogger.info('[egg-multipart] %s mode enable', options.mode); - if (options.mode === 'file' || options.fileModeMatch) { - this.app.coreLogger.info('[egg-multipart] will save temporary files to %j, cleanup job cron: %j', options.tmpdir, options.cleanSchedule.cron); - // enable multipart middleware - this.app.config.coreMiddleware.push('multipart'); - } - } -}; - diff --git a/app/middleware/multipart.js b/app/middleware/multipart.js deleted file mode 100644 index ece8bf2..0000000 --- a/app/middleware/multipart.js +++ /dev/null @@ -1,17 +0,0 @@ -const pathMatching = require('egg-path-matching'); - -module.exports = (options, app) => { - // normalize - const matchFn = options.fileModeMatch && pathMatching({ - match: options.fileModeMatch, - pathToRegexpModule: app.options.pathToRegexpModule, - }); - - return async function multipart(ctx, next) { - if (!ctx.is('multipart')) return next(); - if (matchFn && !matchFn(ctx)) return next(); - - await ctx.saveRequestFiles(); - return next(); - }; -}; diff --git a/config/config.default.js b/config/config.default.js deleted file mode 100644 index 4d0bf8b..0000000 --- a/config/config.default.js +++ /dev/null @@ -1,52 +0,0 @@ -'use strict'; - -const os = require('os'); -const path = require('path'); - -module.exports = appInfo => { - const config = {}; - - /** - * multipart parser options - * @member Config#multipart - * @property {String} mode - which mode to handle multipart request, default is `stream`, the hard way. - * If set mode to `file`, it's the easy way to handle multipart request and save it to local files. - * If you don't know the Node.js Stream work, maybe you should use the `file` mode to get started. - * @property {String | RegExp | Function | Array} fileModeMatch - special url to use file mode when global `mode` is `stream`. - * @property {Boolean} autoFields - Auto set fields to parts, default is `false`. Only work on `stream` mode. - * If set true,all fields will be auto handle and can acces by `parts.fields` - * @property {String} defaultCharset - Default charset encoding, don't change it before you real know about it - * @property {String} defaultParamCharset - For multipart forms, the default character set to use for values of part header parameters (e.g. filename) that are not extended parameters (that contain an explicit charset), don't change it before you real know about it - * @property {Integer} fieldNameSize - Max field name size (in bytes), default is `100` - * @property {String|Integer} fieldSize - Max field value size (in bytes), default is `100kb` - * @property {Integer} fields - Max number of non-file fields, default is `10` - * @property {String|Integer} fileSize - Max file size (in bytes), default is `10mb`, means should <10mb not <=10mb. - * @property {Integer} files - Max number of file fields, default is `10` - * @property {Array|Function} whitelist - The white ext file names, default is `null` - * @property {Array} fileExtensions - Add more ext file names to the `whitelist`, default is `[]`, only valid when `whitelist` is `null` - * @property {String} tmpdir - The directory for temporary files. Only work on `file` mode. - */ - config.multipart = { - mode: 'stream', - autoFields: false, - defaultCharset: 'utf8', - defaultParamCharset: 'utf8', - fieldNameSize: 100, - fieldSize: '100kb', - fields: 10, - fileSize: '10mb', - files: 10, - fileExtensions: [], - whitelist: null, - allowArrayField: false, - tmpdir: path.join(os.tmpdir(), 'egg-multipart-tmp', appInfo.name), - cleanSchedule: { - // run tmpdir clean job on every day 04:30 am - // cron style see https://github.com/eggjs/egg-schedule#cron-style-scheduling - cron: '0 30 4 * * *', - disable: false, - }, - }; - - return config; -}; diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index 82ccd0c..0000000 --- a/index.d.ts +++ /dev/null @@ -1,119 +0,0 @@ -import 'egg'; -import { Readable } from 'stream'; -interface EggFile { - field: string; - filename: string; - encoding: string; - mime: string; - filepath: string; -} - -interface MultipartOptions { - autoFields?: boolean; - requireFile?: boolean; // required file submit, default is true - defaultCharset?: string; - defaultParamCharset?: string; - defCharset?: string; // compatible with defaultCharset, use `defaultCharset` instead - limits?: { - fieldNameSize?: number; - fieldSize?: number; - fields?: number; - fileSize?: number; - files?: number; - parts?: number; - headerPairs?: number; - }; - checkFile?( - fieldname: string, - file: any, - filename: string, - encoding: string, - mimetype: string - ): void | Error; -} - -interface MultipartFileStream extends Readable { - fields: any; - filename: string; - fieldname: string; - mime: string; - mimeType: string; - transferEncoding: string; - encoding: string; - truncated: boolean; -} - -interface ScheduleOptions { - type?: string; - cron?: string; - cronOptions?: { - tz?: string; - utc?: boolean; - iterator?: boolean; - currentDate?: string|number|Date; - endDate?: string|number|Date; - }; - interval?: number|string; - immediate?: boolean; - disable?: boolean; - env?: string[]; -} - -declare module 'egg' { - interface Context { - /** - * clean up request tmp files helper - * @param {EggFile[]} files file paths need to clenup, default is `ctx.request.files`. - * @return {Promise} - */ - cleanupRequestFiles(files?: EggFile[]): Promise; - - /** - * save request multipart data and files to `ctx.request` - * @return {Promise} - */ - saveRequestFiles(): Promise; - - /** - * create multipart.parts instance, to get separated files. - * @param {MultipartOptions} options - * @return {Function} return a function which return a Promise - */ - multipart(options?: MultipartOptions): (fn?: Function) => Promise; - - /** - * get upload file stream - * @param {MultipartOptions} options - * @return {Promise} - */ - getFileStream(options?: MultipartOptions): Promise - } - - interface Request { - /** - * Files Object Array - */ - files: EggFile[]; - } - - type MatchItem = string | RegExp | ((ctx: Context) => boolean); - - interface EggAppConfig { - multipart: { - mode?: string; - fileModeMatch?: MatchItem | MatchItem[]; - autoFields?: boolean; - defaultCharset?: string; - defaultParamCharset?: string; - fieldNameSize?: number; - fieldSize?: string|number; - fields?: number; - fileSize?: string|number; - files?: number; - whitelist?: ((filename: string) => boolean)|string[]; - fileExtensions?: string[]; - tmpdir?: string; - cleanSchedule?: ScheduleOptions; - } - } -} diff --git a/package.json b/package.json index d198650..e816565 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,24 @@ { - "name": "egg-multipart", + "name": "@eggjs/multipart", "version": "3.5.0", + "publishConfig": { + "access": "public" + }, "eggPlugin": { "name": "multipart", "optionalDependencies": [ "schedule" - ] + ], + "exports": { + "import": "./dist/esm", + "require": "./dist/commonjs", + "typescript": "./src" + } }, "description": "multipart plugin for egg", - "main": "index.js", - "scripts": { - "lint": "eslint .", - "test": "npm run lint -- --fix && npm run test-local", - "test-local": "egg-bin test --ts false", - "cov": "egg-bin cov --ts false", - "ci": "npm run lint && npm run cov" - }, "repository": { "type": "git", - "url": "git+https://github.com/eggjs/egg-multipart.git" + "url": "git+https://github.com/eggjs/multipart.git" }, "keywords": [ "egg", @@ -31,38 +31,72 @@ "bugs": { "url": "https://github.com/eggjs/egg/issues" }, - "homepage": "https://github.com/eggjs/egg-multipart#readme", + "homepage": "https://github.com/eggjs/multipart#readme", "engines": { - "node": ">= 14.17.0" + "node": ">= 18.19.0" }, - "files": [ - "lib", - "app", - "config", - "app.js", - "index.d.ts" - ], - "types": "index.d.ts", "dependencies": { - "co-busboy": "^2.0.0", + "@eggjs/core": "^6.3.1", + "bytes": "^3.1.2", + "co-busboy": "^2.0.1", "dayjs": "^1.11.5", - "egg-path-matching": "^1.2.0", - "humanize-bytes": "^1.0.1" + "egg-path-matching": "^2.1.0" }, "devDependencies": { - "@eggjs/tsconfig": "^1.2.0", - "@types/node": "^20.14.2", - "coffee": "^5.4.0", - "egg": "^3.9.0", - "egg-bin": "6", - "egg-mock": "^5.4.0", - "eslint": "^8.23.1", - "eslint-config-egg": "^12", + "@arethetypeswrong/cli": "^0.17.1", + "@eggjs/bin": "7", + "@eggjs/mock": "^6.0.5", + "@eggjs/tsconfig": "1", + "@types/bytes": "^3.1.5", + "@types/mocha": "10", + "@types/node": "22", + "egg": "^4.0.6", + "eslint": "8", + "eslint-config-egg": "14", "formstream": "^1.5.1", - "is-type-of": "^1.2.1", - "typescript": "5", - "urllib": "3", + "is-type-of": "2", + "path-to-regexp-v8": "npm:path-to-regexp@8", + "rimraf": "6", "stream-wormhole": "^2.0.1", - "path-to-regexp-v8": "npm:path-to-regexp@8" - } + "tshy": "3", + "tshy-after": "1", + "typescript": "5", + "urllib": "4" + }, + "scripts": { + "lint": "eslint --cache src test --ext .ts", + "pretest": "npm run clean && npm run lint -- --fix", + "test": "egg-bin test", + "preci": "npm run clean && npm run lint", + "ci": "egg-bin cov", + "postci": "npm run prepublishOnly && npm run clean", + "clean": "rimraf dist", + "prepublishOnly": "tshy && tshy-after && attw --pack" + }, + "type": "module", + "tshy": { + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + } + }, + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src" + ], + "types": "./dist/commonjs/index.d.ts", + "main": "./dist/commonjs/index.js" } diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..563af8e --- /dev/null +++ b/src/app.ts @@ -0,0 +1,20 @@ +import type { EggCore, ILifecycleBoot } from '@eggjs/core'; +import { normalizeOptions } from './lib/utils.js'; + +export default class AppBootHook implements ILifecycleBoot { + constructor(private app: EggCore) {} + + configWillLoad() { + this.app.config.multipart = normalizeOptions(this.app.config.multipart); + const options = this.app.config.multipart; + + this.app.coreLogger.info('[@eggjs/multipart] %s mode enable', options.mode); + if (options.mode === 'file' || options.fileModeMatch) { + this.app.coreLogger.info('[@eggjs/multipart] will save temporary files to %j, cleanup job cron: %j', + options.tmpdir, options.cleanSchedule.cron); + // enable multipart middleware + this.app.config.coreMiddleware.push('multipart'); + } + } +} + diff --git a/app/extend/context.js b/src/app/extend/context.ts similarity index 60% rename from app/extend/context.js rename to src/app/extend/context.ts index c53ed8c..88d0ec6 100644 --- a/app/extend/context.js +++ b/src/app/extend/context.ts @@ -1,29 +1,79 @@ -'use strict'; - -const assert = require('assert'); -const path = require('path'); -const { randomUUID } = require('crypto'); -const parse = require('co-busboy'); -const fs = require('fs').promises; -const { createWriteStream } = require('fs'); -const bytes = require('humanize-bytes'); -const dayjs = require('dayjs'); -const stream = require('stream'); -const { Readable, PassThrough } = stream; -const util = require('util'); -const pipeline = util.promisify(stream.pipeline); - -class LimitError extends Error { - constructor(code, message) { - super(message); - this.code = code; - this.status = 413; - } -} +import assert from 'node:assert'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { createWriteStream } from 'node:fs'; +import { Readable, PassThrough } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +// @ts-expect-error no types +import parse from 'co-busboy'; +import dayjs from 'dayjs'; +import { Context } from '@eggjs/core'; +import { humanizeBytes } from '../../lib/utils.js'; +import { LimitError } from '../../lib/LimitError.js'; +import { MultipartFileTooLargeError } from '../../lib/MultipartFileTooLargeError.js'; const HAS_CONSUMED = Symbol('Context#multipartHasConsumed'); -module.exports = { +export interface EggFile { + field: string; + filename: string; + encoding: string; + mime: string; + filepath: string; +} + +export interface MultipartFileStream extends Readable { + fields: Record; + filename: string; + fieldname: string; + mime: string; + mimeType: string; + transferEncoding: string; + encoding: string; + truncated: boolean; +} + +export interface MultipartOptions { + autoFields?: boolean; + /** + * required file submit, default is true + */ + requireFile?: boolean; + /** + * default charset encoding + */ + defaultCharset?: string; + /** + * compatible with defaultCharset + * @deprecated use `defaultCharset` instead + */ + defCharset?: string; + defaultParamCharset?: string; + /** + * compatible with defaultParamCharset + * @deprecated use `defaultParamCharset` instead + */ + defParamCharset?: string; + limits?: { + fieldNameSize?: number; + fieldSize?: number; + fields?: number; + fileSize?: number; + files?: number; + parts?: number; + headerPairs?: number; + }; + checkFile?( + fieldname: string, + file: any, + filename: string, + encoding: string, + mimetype: string + ): void | Error; +} + +export default class MultipartContext extends Context { /** * create multipart.parts instance, to get separated files. * @function Context#multipart @@ -35,7 +85,8 @@ module.exports = { * - {Function} options.checkFile * @return {Yieldable | AsyncIterable} parts */ - multipart(options) { + multipart(options: MultipartOptions = {}): AsyncIterable { + // eslint-disable-next-line @typescript-eslint/no-this-alias const ctx = this; // multipart/form-data if (!ctx.is('multipart')) ctx.throw(400, 'Content-Type must be multipart/*'); @@ -67,7 +118,7 @@ module.exports = { // mount asyncIterator, so we can use `for await` to get parts const parts = parse(this, parseOptions); parts[Symbol.asyncIterator] = async function* () { - let part; + let part: MultipartFileStream | undefined; do { part = await parts(); @@ -93,7 +144,7 @@ module.exports = { // in case of emit 'limit' too fast throw new LimitError('Request_fileSize_limit', 'Reach fileSize limit'); } else { - part.once('limit', function() { + part.once('limit', function(this: MultipartFileStream) { this.emit('error', new LimitError('Request_fileSize_limit', 'Reach fileSize limit')); this.resume(); }); @@ -106,22 +157,23 @@ module.exports = { } while (part !== undefined); }; return parts; - }, + } /** * save request multipart data and files to `ctx.request` * @function Context#saveRequestFiles * @param {Object} options - { limits, checkFile, ... } */ - async saveRequestFiles(options = {}) { + async saveRequestFiles(options: MultipartOptions = {}) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const ctx = this; const allowArrayField = ctx.app.config.multipart.allowArrayField; - let storedir; + let storeDir: string | undefined; - const requestBody = {}; - const requestFiles = []; + const requestBody: Record = {}; + const requestFiles: EggFile[] = []; options.autoFields = false; const parts = ctx.multipart(options); @@ -146,14 +198,14 @@ module.exports = { // stream const { filename, fieldname, encoding, mime } = part; - if (!storedir) { + if (!storeDir) { // ${tmpdir}/YYYY/MM/DD/HH - storedir = path.join(ctx.app.config.multipart.tmpdir, dayjs().format('YYYY/MM/DD/HH')); - await fs.mkdir(storedir, { recursive: true }); + storeDir = path.join(ctx.app.config.multipart.tmpdir, dayjs().format('YYYY/MM/DD/HH')); + await fs.mkdir(storeDir, { recursive: true }); } // write to tmp file - const filepath = path.join(storedir, randomUUID() + path.extname(filename)); + const filepath = path.join(storeDir, randomUUID() + path.extname(filename)); const target = createWriteStream(filepath); await pipeline(part, target); @@ -179,7 +231,7 @@ module.exports = { ctx.request.body = requestBody; ctx.request.files = requestFiles; - }, + } /** * get upload file stream @@ -198,11 +250,12 @@ module.exports = { * - {Function} options.checkFile * @return {ReadStream} stream * @since 1.0.0 + * @deprecated Not safe enough, use `ctx.multipart()` instead */ - async getFileStream(options = {}) { + async getFileStream(options: MultipartOptions = {}): Promise { options.autoFields = true; - const parts = this.multipart(options); - let stream = await parts(); + const parts: any = this.multipart(options); + let stream: MultipartFileStream = await parts(); if (options.requireFile !== false) { // stream not exists, treat as an exception @@ -212,7 +265,7 @@ module.exports = { } if (!stream) { - stream = Readable.from([]); + stream = Readable.from([]) as MultipartFileStream; } if (stream.truncated) { @@ -221,31 +274,28 @@ module.exports = { stream.fields = parts.field; stream.once('limit', () => { - const err = new Error('Request file too large, please check multipart config'); - err.name = 'MultipartFileTooLargeError'; - err.status = 413; - err.fields = stream.fields; - err.filename = stream.filename; + const err = new MultipartFileTooLargeError( + 'Request file too large, please check multipart config', stream.fields, stream.filename); if (stream.listenerCount('error') > 0) { stream.emit('error', err); this.coreLogger.warn(err); } else { this.coreLogger.error(err); // ignore next error event - stream.on('error', () => { }); + stream.on('error', () => {}); } // ignore all data stream.resume(); }); return stream; - }, + } /** * clean up request tmp files helper * @function Context#cleanupRequestFiles - * @param {Array} [files] - file paths need to clenup, default is `ctx.request.files`. + * @param {Array} [files] - file paths need to cleanup, default is `ctx.request.files`. */ - async cleanupRequestFiles(files) { + async cleanupRequestFiles(files?: EggFile[]) { if (!files || !files.length) { files = this.request.files; } @@ -259,30 +309,60 @@ module.exports = { } } } - }, -}; + } +} -function extractOptions(options = {}) { - const opts = {}; - if (typeof options.autoFields === 'boolean') opts.autoFields = options.autoFields; - if (options.limits) opts.limits = options.limits; - if (options.checkFile) opts.checkFile = options.checkFile; +function extractOptions(options: MultipartOptions = {}) { + const opts: MultipartOptions = {}; + if (typeof options.autoFields === 'boolean') { + opts.autoFields = options.autoFields; + } + if (options.limits) { + opts.limits = options.limits; + } + if (options.checkFile) { + opts.checkFile = options.checkFile; + } - if (options.defCharset) opts.defCharset = options.defCharset; - if (options.defParamCharset) opts.defParamCharset = options.defParamCharset; + if (options.defCharset) { + opts.defCharset = options.defCharset; + } + if (options.defParamCharset) { + opts.defParamCharset = options.defParamCharset; + } // compatible with config names - if (options.defaultCharset) opts.defCharset = options.defaultCharset; - if (options.defaultParamCharset) opts.defParamCharset = options.defaultParamCharset; + if (options.defaultCharset) { + opts.defCharset = options.defaultCharset; + } + if (options.defaultParamCharset) { + opts.defParamCharset = options.defaultParamCharset; + } // limits if (options.limits) { - opts.limits = Object.assign({}, options.limits); - for (const key in opts.limits) { - if (key.endsWith('Size') && opts.limits[key]) { - opts.limits[key] = bytes(opts.limits[key]); + const limits: Record = opts.limits = { ...options.limits }; + for (const key in limits) { + if (key.endsWith('Size') && limits[key]) { + limits[key] = humanizeBytes(limits[key]); } } } return opts; } + +declare module '@eggjs/core' { + interface Request { + /** + * Files Object Array + */ + files?: EggFile[]; + } + + interface Context { + saveRequestFiles(options?: MultipartOptions): Promise; + getFileStream(options?: MultipartOptions): Promise; + cleanupRequestFiles(files?: EggFile[]): Promise; + } +} + diff --git a/src/app/middleware/multipart.ts b/src/app/middleware/multipart.ts new file mode 100644 index 0000000..c0efa1e --- /dev/null +++ b/src/app/middleware/multipart.ts @@ -0,0 +1,20 @@ +import { pathMatching } from 'egg-path-matching'; +import type { Context, Next, EggCore } from '@eggjs/core'; +import type { MultipartConfig } from '/src/config/config.default.js'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default (options: MultipartConfig, _app: EggCore) => { + // normalize options + const matchFn = options.fileModeMatch && pathMatching({ + match: options.fileModeMatch, + // pathToRegexpModule: app.options.pathToRegexpModule, + }); + + return async function multipart(ctx: Context, next: Next) { + if (!ctx.is('multipart')) return next(); + if (matchFn && !matchFn(ctx)) return next(); + + await ctx.saveRequestFiles(); + return next(); + }; +}; diff --git a/app/schedule/clean_tmpdir.js b/src/app/schedule/clean_tmpdir.ts similarity index 66% rename from app/schedule/clean_tmpdir.js rename to src/app/schedule/clean_tmpdir.ts index 0753f28..41d1a23 100644 --- a/app/schedule/clean_tmpdir.js +++ b/src/app/schedule/clean_tmpdir.ts @@ -1,10 +1,9 @@ -'use strict'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import dayjs from 'dayjs'; +import { EggCore } from '@eggjs/core'; -const path = require('path'); -const fs = require('fs').promises; -const dayjs = require('dayjs'); - -module.exports = app => { +export default (app: EggCore): any => { return class CleanTmpdir extends app.Subscription { static get schedule() { return { @@ -15,16 +14,16 @@ module.exports = app => { }; } - async _remove(dir) { + async _remove(dir: string) { const { ctx } = this; if (await fs.access(dir).then(() => true, () => false)) { - ctx.coreLogger.info('[egg-multipart:CleanTmpdir] removing tmpdir: %j', dir); + ctx.coreLogger.info('[@eggjs/multipart:CleanTmpdir] removing tmpdir: %j', dir); try { await fs.rm(dir, { force: true, recursive: true }); - ctx.coreLogger.info('[egg-multipart:CleanTmpdir:success] tmpdir: %j has been removed', dir); + ctx.coreLogger.info('[@eggjs/multipart:CleanTmpdir:success] tmpdir: %j has been removed', dir); } catch (err) { /* c8 ignore next 3 */ - ctx.coreLogger.error('[egg-multipart:CleanTmpdir:error] remove tmpdir: %j error: %s', dir, err); + ctx.coreLogger.error('[@eggjs/multipart:CleanTmpdir:error] remove tmpdir: %j error: %s', dir, err); ctx.coreLogger.error(err); } } @@ -33,7 +32,7 @@ module.exports = app => { async subscribe() { const { ctx } = this; const config = ctx.app.config; - ctx.coreLogger.info('[egg-multipart:CleanTmpdir] start clean tmpdir: %j', config.multipart.tmpdir); + ctx.coreLogger.info('[@eggjs/multipart:CleanTmpdir] start clean tmpdir: %j', config.multipart.tmpdir); // last year const lastYear = dayjs().subtract(1, 'years'); const lastYearDir = path.join(config.multipart.tmpdir, lastYear.format('YYYY')); @@ -50,7 +49,7 @@ module.exports = app => { const dir = path.join(config.multipart.tmpdir, date.format('YYYY/MM/DD')); await this._remove(dir); } - ctx.coreLogger.info('[egg-multipart:CleanTmpdir] end'); + ctx.coreLogger.info('[@eggjs/multipart:CleanTmpdir] end'); } }; }; diff --git a/src/config/config.default.ts b/src/config/config.default.ts new file mode 100644 index 0000000..044cfb7 --- /dev/null +++ b/src/config/config.default.ts @@ -0,0 +1,130 @@ +import os from 'node:os'; +import path from 'node:path'; +import type { Context, EggAppInfo } from '@eggjs/core'; +import type { PathMatchingPattern } from 'egg-path-matching'; + +export type MatchItem = string | RegExp | ((ctx: Context) => boolean); + +/** + * multipart parser options + * @member Config#multipart + */ +export interface MultipartConfig { + /** + * which mode to handle multipart request, default is `stream`, the hard way. + * If set mode to `file`, it's the easy way to handle multipart request and save it to local files. + * If you don't know the Node.js Stream work, maybe you should use the `file` mode to get started. + */ + mode: 'stream' | 'file'; + /** + * special url to use file mode when global `mode` is `stream`. + */ + fileModeMatch?: PathMatchingPattern; + /** + * Auto set fields to parts, default is `false`. + * Only work on `stream` mode. + * If set true,all fields will be auto handle and can access by `parts.fields` + */ + autoFields: boolean; + /** + * default charset encoding, don't change it before you real know about it + * Default is `utf8` + */ + defaultCharset: string; + /** + * For multipart forms, the default character set to use for values of part header parameters (e.g. filename) + * that are not extended parameters (that contain an explicit charset), don't change it before you real know about it + * Default is `utf8` + */ + defaultParamCharset: string; + /** + * Max field name size (in bytes), default is `100` + */ + fieldNameSize: number; + /** + * Max field value size (in bytes), default is `100kb` + */ + fieldSize: string | number; + /** + * Max number of non-file fields, default is `10` + */ + fields: number; + /** + * Max file size (in bytes), default is `10mb` + */ + fileSize: string | number; + /** + * Max number of file fields, default is `10` + */ + files: number; + /** + * Add more ext file names to the `whitelist`, default is `[]`, only valid when `whitelist` is `null` + */ + fileExtensions: string[]; + /** + * The white ext file names, default is `null` + */ + whitelist: string[] | ((filename: string) => boolean) | null; + /** + * Allow array field, default is `false` + */ + allowArrayField: boolean; + /** + * The directory for temporary files. Only work on `file` mode. + * Default is `os.tmpdir()/egg-multipart-tmp/${appInfo.name}` + */ + tmpdir: string; + /** + * The schedule for cleaning temporary files. Only work on `file` mode. + */ + cleanSchedule: { + /** + * The cron expression for the schedule. + * Default is `0 30 4 * * *` + * @see https://github.com/eggjs/egg-schedule#cron-style-scheduling + */ + cron: string; + /** + * Default is `false` + */ + disable: boolean; + }; + checkFile?( + fieldname: string, + file: any, + filename: string, + encoding: string, + mimetype: string + ): void | Error; +} + +export default (appInfo: EggAppInfo) => { + return { + multipart: { + mode: 'stream', + autoFields: false, + defaultCharset: 'utf8', + defaultParamCharset: 'utf8', + fieldNameSize: 100, + fieldSize: '100kb', + fields: 10, + fileSize: '10mb', + files: 10, + fileExtensions: [], + whitelist: null, + allowArrayField: false, + tmpdir: path.join(os.tmpdir(), 'egg-multipart-tmp', appInfo.name), + cleanSchedule: { + cron: '0 30 4 * * *', + disable: false, + }, + } as MultipartConfig, + }; +}; + +declare module '@eggjs/core' { + // add EggAppConfig overrides types + interface EggAppConfig { + multipart: MultipartConfig; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4ec3185 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +import './config/config.default.js'; +import './app/extend/context.js'; diff --git a/src/lib/LimitError.ts b/src/lib/LimitError.ts new file mode 100644 index 0000000..fc20d4b --- /dev/null +++ b/src/lib/LimitError.ts @@ -0,0 +1,12 @@ +export class LimitError extends Error { + code: string; + status: number; + + constructor(code: string, message: string) { + super(message); + this.code = code; + this.status = 413; + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/src/lib/MultipartFileTooLargeError.ts b/src/lib/MultipartFileTooLargeError.ts new file mode 100644 index 0000000..02f9978 --- /dev/null +++ b/src/lib/MultipartFileTooLargeError.ts @@ -0,0 +1,14 @@ +export class MultipartFileTooLargeError extends Error { + status: number; + fields: Record; + filename: string; + + constructor(message: string, fields: Record, filename: string) { + super(message); + this.name = this.constructor.name; + this.status = 413; + this.fields = fields; + this.filename = filename; + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/lib/utils.js b/src/lib/utils.ts similarity index 59% rename from lib/utils.js rename to src/lib/utils.ts index 9a6e72e..78dc2f7 100644 --- a/lib/utils.js +++ b/src/lib/utils.ts @@ -1,11 +1,9 @@ -'use strict'; +import path from 'node:path'; +import assert from 'node:assert'; +import bytes from 'bytes'; +import { MultipartConfig } from '../config/config.default.js'; -const bytes = require('humanize-bytes'); -const path = require('path'); -const assert = require('assert'); - - -exports.whitelist = [ +export const whitelist = [ // images '.jpg', '.jpeg', // image/jpeg '.png', // image/png, image/x-png @@ -29,21 +27,32 @@ exports.whitelist = [ '.mp3', '.mp4', '.avi', -]; +] as const; + +export function humanizeBytes(size: number | string) { + if (typeof size === 'number') { + return size; + } + return bytes(size) as number; +} -exports.normalizeOptions = options => { +export function normalizeOptions(options: MultipartConfig) { // make sure to cast the value of config **Size to number - options.fileSize = bytes(options.fileSize); - options.fieldSize = bytes(options.fieldSize); - options.fieldNameSize = bytes(options.fieldNameSize); + options.fileSize = humanizeBytes(options.fileSize); + options.fieldSize = humanizeBytes(options.fieldSize); + options.fieldNameSize = humanizeBytes(options.fieldNameSize); // validate mode options.mode = options.mode || 'stream'; assert([ 'stream', 'file' ].includes(options.mode), `Expect mode to be 'stream' or 'file', but got '${options.mode}'`); - if (options.mode === 'file') assert(!options.fileModeMatch, '`fileModeMatch` options only work on stream mode, please remove it'); + if (options.mode === 'file') { + assert(!options.fileModeMatch, '`fileModeMatch` options only work on stream mode, please remove it'); + } // normalize whitelist - if (Array.isArray(options.whitelist)) options.whitelist = options.whitelist.map(extname => extname.toLowerCase()); + if (Array.isArray(options.whitelist)) { + options.whitelist = options.whitelist.map(extname => extname.toLowerCase()); + } // normalize fileExtensions if (Array.isArray(options.fileExtensions)) { @@ -52,7 +61,7 @@ exports.normalizeOptions = options => { }); } - function checkExt(fileName) { + function checkExt(fileName: string) { if (typeof options.whitelist === 'function') return options.whitelist(fileName); const extname = path.extname(fileName).toLowerCase(); if (Array.isArray(options.whitelist)) return options.whitelist.includes(extname); @@ -60,20 +69,20 @@ exports.normalizeOptions = options => { return exports.whitelist.includes(extname) || options.fileExtensions.includes(extname); } - options.checkFile = (fieldName, fileStream, fileName) => { + options.checkFile = (_fieldName: string, fileStream: any, fileName: string): void | Error => { // just ignore, if no file - if (!fileStream || !fileName) return null; + if (!fileStream || !fileName) return; try { if (!checkExt(fileName)) { const err = new Error('Invalid filename: ' + fileName); - err.status = 400; + Reflect.set(err, 'status', 400); return err; } - } catch (err) { + } catch (err: any) { err.status = 400; return err; } }; return options; -}; +} diff --git a/src/typings/index.d.ts b/src/typings/index.d.ts new file mode 100644 index 0000000..53c65c7 --- /dev/null +++ b/src/typings/index.d.ts @@ -0,0 +1,4 @@ +// make sure to import egg typings and let typescript know about it +// @see https://github.com/whxaxes/blog/issues/11 +// and https://www.typescriptlang.org/docs/handbook/declaration-merging.html +import 'egg'; diff --git a/test/wrong-mode.test.js b/test/wrong-mode.test.js deleted file mode 100644 index d5aaf03..0000000 --- a/test/wrong-mode.test.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const mock = require('egg-mock'); - -describe('test/wrong-mode.test.js', () => { - it('should start fail when mode=foo', () => { - const app = mock.app({ - baseDir: 'apps/wrong-mode', - }); - return app.ready() - .then(() => { - throw new Error('should not run this'); - }, err => { - assert(err.message === 'Expect mode to be \'stream\' or \'file\', but got \'foo\''); - }); - }); - - it('should start fail when using options.fileModeMatch on file mode', () => { - const app = mock.app({ - baseDir: 'apps/wrong-fileModeMatch', - }); - return app.ready() - .then(() => { - throw new Error('should not run this'); - }, err => { - assert(err.message === '`fileModeMatch` options only work on stream mode, please remove it'); - }); - }); -}); diff --git a/test/wrong-mode.test.ts b/test/wrong-mode.test.ts new file mode 100644 index 0000000..31a8c56 --- /dev/null +++ b/test/wrong-mode.test.ts @@ -0,0 +1,27 @@ +import { strict as assert } from 'node:assert'; +import { mm, MockApplication } from '@eggjs/mock'; + +describe('test/wrong-mode.test.ts', () => { + let app: MockApplication; + afterEach(async () => { + await app.close(); + }); + + it('should start fail when mode=foo', async () => { + app = mm.app({ + baseDir: 'apps/wrong-mode', + }); + await assert.rejects(async () => { + await app.ready(); + }, /Expect mode to be 'stream' or 'file', but got 'foo'/); + }); + + it('should start fail when using options.fileModeMatch on file mode', async () => { + app = mm.app({ + baseDir: 'apps/wrong-fileModeMatch', + }); + await assert.rejects(async () => { + await app.ready(); + }, /`fileModeMatch` options only work on stream mode, please remove it/); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ff41b73 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@eggjs/tsconfig", + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} From c48a1eca327e2891d9b330f17a1033bdf474c87e Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Mon, 3 Feb 2025 22:22:04 +0800 Subject: [PATCH 2/4] f --- package.json | 3 +- src/app/middleware/multipart.ts | 2 +- src/lib/utils.ts | 14 +- ...-option.test.js => dynamic-option.test.ts} | 28 ++- test/{file-mode.test.js => file-mode.test.ts} | 112 ++++++------ .../fixtures/apps/ts/config/config.default.ts | 18 +- .../app/controller/upload.js | 10 +- .../apps/whitelist-function/app/router.js | 2 - ...it.test.js => multipart-for-await.test.ts} | 70 ++++---- test/{multipart.test.js => multipart.test.ts} | 162 +++++++++--------- test/ts.test.js | 83 --------- test/ts.test.ts | 74 ++++++++ 12 files changed, 289 insertions(+), 289 deletions(-) rename test/{dynamic-option.test.js => dynamic-option.test.ts} (66%) rename test/{file-mode.test.js => file-mode.test.ts} (83%) rename test/{multipart-for-await.test.js => multipart-for-await.test.ts} (73%) rename test/{multipart.test.js => multipart.test.ts} (88%) delete mode 100644 test/ts.test.js create mode 100644 test/ts.test.ts diff --git a/package.json b/package.json index e816565..a6a94c5 100644 --- a/package.json +++ b/package.json @@ -98,5 +98,6 @@ "src" ], "types": "./dist/commonjs/index.d.ts", - "main": "./dist/commonjs/index.js" + "main": "./dist/commonjs/index.js", + "module": "./dist/esm/index.js" } diff --git a/src/app/middleware/multipart.ts b/src/app/middleware/multipart.ts index c0efa1e..9c82d24 100644 --- a/src/app/middleware/multipart.ts +++ b/src/app/middleware/multipart.ts @@ -1,6 +1,6 @@ import { pathMatching } from 'egg-path-matching'; import type { Context, Next, EggCore } from '@eggjs/core'; -import type { MultipartConfig } from '/src/config/config.default.js'; +import type { MultipartConfig } from '../../config/config.default.js'; // eslint-disable-next-line @typescript-eslint/no-unused-vars export default (options: MultipartConfig, _app: EggCore) => { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 78dc2f7..504a94e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -15,7 +15,7 @@ export const whitelist = [ '.psd', // text '.svg', - '.js', '.jsx', + '.js', '.jsx', '.ts', '.tsx', '.json', '.css', '.less', '.html', '.htm', @@ -27,7 +27,7 @@ export const whitelist = [ '.mp3', '.mp4', '.avi', -] as const; +]; export function humanizeBytes(size: number | string) { if (typeof size === 'number') { @@ -62,11 +62,15 @@ export function normalizeOptions(options: MultipartConfig) { } function checkExt(fileName: string) { - if (typeof options.whitelist === 'function') return options.whitelist(fileName); + if (typeof options.whitelist === 'function') { + return options.whitelist(fileName); + } const extname = path.extname(fileName).toLowerCase(); - if (Array.isArray(options.whitelist)) return options.whitelist.includes(extname); + if (Array.isArray(options.whitelist)) { + return options.whitelist.includes(extname); + } // only if user don't provide whitelist, we will use default whitelist + fileExtensions - return exports.whitelist.includes(extname) || options.fileExtensions.includes(extname); + return whitelist.includes(extname) || options.fileExtensions.includes(extname); } options.checkFile = (_fieldName: string, fileStream: any, fileName: string): void | Error => { diff --git a/test/dynamic-option.test.js b/test/dynamic-option.test.ts similarity index 66% rename from test/dynamic-option.test.js rename to test/dynamic-option.test.ts index 82fce07..240445f 100644 --- a/test/dynamic-option.test.js +++ b/test/dynamic-option.test.ts @@ -1,17 +1,15 @@ -'use strict'; +import { strict as assert } from 'node:assert'; +import fs from 'node:fs/promises'; +import formstream from 'formstream'; +import urllib from 'urllib'; +import { mm, MockApplication } from '@eggjs/mock'; -const assert = require('assert'); -const formstream = require('formstream'); -const urllib = require('urllib'); -const mock = require('egg-mock'); -const fs = require('fs').promises; - -describe('test/dynamic-option.test.js', () => { - let app; - let server; - let host; +describe('test/dynamic-option.test.ts', () => { + let app: MockApplication; + let server: any; + let host: string; before(() => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/dynamic-option', }); return app.ready(); @@ -26,7 +24,7 @@ describe('test/dynamic-option.test.js', () => { after(() => app.close()); after(() => server.close()); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(() => mm.restore()); it('should work with saveRequestFiles options', async () => { const form = formstream(); @@ -36,11 +34,11 @@ describe('test/dynamic-option.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, // dataType: 'json', }); - assert(res.status === 413); + assert.equal(res.status, 413); assert.match(res.data.toString(), /Error: Reach fileSize limit/); }); }); diff --git a/test/file-mode.test.js b/test/file-mode.test.ts similarity index 83% rename from test/file-mode.test.js rename to test/file-mode.test.ts index d9373a4..0f1e0ae 100644 --- a/test/file-mode.test.js +++ b/test/file-mode.test.ts @@ -1,20 +1,22 @@ -'use strict'; - -const assert = require('assert'); -const formstream = require('formstream'); -const urllib = require('urllib'); -const path = require('path'); -const mock = require('egg-mock'); -const fs = require('fs').promises; -const dayjs = require('dayjs'); -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); - -describe('test/file-mode.test.js', () => { - let app; - let server; - let host; +import assert from 'node:assert'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { scheduler } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { mm, mock, MockApplication } from '@eggjs/mock'; +import dayjs from 'dayjs'; +import formstream from 'formstream'; +import urllib from 'urllib'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('test/file-mode.test.ts', () => { + let app: MockApplication; + let server: any; + let host: string; before(() => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/file-mode', }); return app.ready(); @@ -29,7 +31,7 @@ describe('test/file-mode.test.js', () => { after(() => app.close()); after(() => server.close()); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(mm.restore); it('should ignore non multipart request', async () => { const res = await app.httpRequest() @@ -62,7 +64,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -70,24 +72,24 @@ describe('test/file-mode.test.js', () => { assert.deepStrictEqual(data.body, { foo: 'fengmk2', love: 'egg', work: 'with Node.js' }); assert(data.files.length === 3); assert(data.files[0].field === 'file1'); - assert(data.files[0].filename === 'foooooooo.js'); + assert.equal(data.files[0].filename, 'foooooooo.js'); assert(data.files[0].encoding === '7bit'); assert(data.files[0].mime === 'application/javascript'); assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); assert(data.files[1].field === 'file2'); assert(data.files[1].fieldname === 'file2'); - assert(data.files[1].filename === 'file-mode.test.js'); + assert.equal(data.files[1].filename, 'file-mode.test.ts'); assert(data.files[1].encoding === '7bit'); assert(data.files[1].transferEncoding === '7bit'); - assert(data.files[1].mime === 'application/javascript'); - assert(data.files[1].mimeType === 'application/javascript'); + assert.equal(data.files[1].mime, 'video/mp2t'); + assert.equal(data.files[1].mimeType, 'video/mp2t'); assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); assert(data.files[2].field === 'bigfile'); - assert(data.files[2].filename === 'bigfile.js'); + assert.equal(data.files[2].filename, 'bigfile.js'); assert(data.files[2].encoding === '7bit'); - assert(data.files[2].mime === 'application/javascript'); + assert.equal(data.files[2].mime, 'application/javascript'); assert(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); }); @@ -98,18 +100,18 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); const data = JSON.parse(res.data); - assert(data.files.length === 1); - assert(data.files[0].field === 'file'); - assert(data.files[0].filename === '10mb.js'); - assert(data.files[0].encoding === '7bit'); - assert(data.files[0].mime === 'application/octet-stream'); + assert.equal(data.files.length, 1); + assert.equal(data.files[0].field, 'file'); + assert.equal(data.files[0].filename, '10mb.js'); + assert.equal(data.files[0].encoding, '7bit'); + assert.equal(data.files[0].mime, 'application/octet-stream'); assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); const stat = await fs.stat(data.files[0].filepath); - assert(stat.size === 10 * 1024 * 1024 - 1); + assert.equal(stat.size, 10 * 1024 * 1024 - 1); }); it('should 200 when field size just 100kb', async () => { @@ -120,7 +122,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -138,7 +140,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -156,7 +158,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -183,7 +185,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 413); @@ -201,7 +203,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 413); @@ -216,7 +218,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 413); @@ -233,7 +235,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 413); @@ -252,7 +254,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 413); @@ -271,7 +273,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 400); @@ -289,10 +291,10 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload?call_multipart_twice=1', { method: 'POST', headers, - stream: form, + stream: form as any, }); - assert(res.status === 500); + assert.equal(res.status, 500); assert(res.data.toString().includes('the multipart request can\'t be consumed twice')); }); @@ -307,7 +309,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload?cleanup=true', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -326,7 +328,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload?async_cleanup=true', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -339,15 +341,15 @@ describe('test/file-mode.test.js', () => { // [egg-schedule]: register schedule /hello/egg-multipart/app/schedule/clean_tmpdir.js const logger = app.loggers.scheduleLogger; const content = await fs.readFile(logger.options.file, 'utf8'); - assert(/\[egg-schedule\]: register schedule .+clean_tmpdir\.js/.test(content)); + assert.match(content, /\[@eggjs\/schedule\]: register schedule .+clean_tmpdir\.ts/); }); it('should remove nothing', async () => { app.mockLog(); - await app.runSchedule(path.join(__dirname, '../app/schedule/clean_tmpdir')); - await sleep(1000); - app.expectLog('[egg-multipart:CleanTmpdir] start clean tmpdir: "', 'coreLogger'); - app.expectLog('[egg-multipart:CleanTmpdir] end', 'coreLogger'); + await app.runSchedule(path.join(__dirname, '../src/app/schedule/clean_tmpdir')); + await scheduler.wait(1000); + app.expectLog('[@eggjs/multipart:CleanTmpdir] start clean tmpdir: "', 'coreLogger'); + app.expectLog('[@eggjs/multipart:CleanTmpdir] end', 'coreLogger'); }); it('should remove old dirs', async () => { @@ -366,7 +368,7 @@ describe('test/file-mode.test.js', () => { const currentMonth = new Date().getMonth(); const fourMonthBefore = path.join(app.config.multipart.tmpdir, dayjs().subtract(4, 'months').format('YYYY/MM/DD/HH')); if (currentMonth < 4) { - // if current month is less than April, four months before shoule be last year. + // if current month is less than April, four months before should be last year. oldDirs.push(fourMonthBefore); } else { shouldKeepDirs.push(fourMonthBefore); @@ -380,7 +382,7 @@ describe('test/file-mode.test.js', () => { })); app.mockLog(); - await app.runSchedule(path.join(__dirname, '../app/schedule/clean_tmpdir')); + await app.runSchedule(path.join(__dirname, '../src/app/schedule/clean_tmpdir')); for (const dir of oldDirs) { const exists = await fs.access(dir).then(() => true).catch(() => false); assert(!exists, dir); @@ -389,8 +391,8 @@ describe('test/file-mode.test.js', () => { const exists = await fs.access(dir).then(() => true).catch(() => false); assert(exists, dir); } - app.expectLog('[egg-multipart:CleanTmpdir] removing tmpdir: "', 'coreLogger'); - app.expectLog('[egg-multipart:CleanTmpdir:success] tmpdir: "', 'coreLogger'); + app.expectLog('[@eggjs/multipart:CleanTmpdir] removing tmpdir: "', 'coreLogger'); + app.expectLog('[@eggjs/multipart:CleanTmpdir:success] tmpdir: "', 'coreLogger'); }); }); @@ -405,7 +407,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); assert.deepStrictEqual(res.data.body, { foo: 'egg' }); @@ -423,7 +425,7 @@ describe('test/file-mode.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); assert.deepStrictEqual(res.data.body, { foo: [ 'fengmk2', 'like', 'egg' ] }); diff --git a/test/fixtures/apps/ts/config/config.default.ts b/test/fixtures/apps/ts/config/config.default.ts index cf65654..fff9c31 100644 --- a/test/fixtures/apps/ts/config/config.default.ts +++ b/test/fixtures/apps/ts/config/config.default.ts @@ -1,4 +1,18 @@ -import { EggAppInfo, EggAppConfig, PowerPartial } from 'egg'; +import { EggAppInfo, EggAppConfig } from 'egg'; + +/** + * Powerful Partial, Support adding ? modifier to a mapped property in deep level + * @example + * import { PowerPartial, EggAppConfig } from 'egg'; + * + * // { view: { defaultEngines: string } } => { view?: { defaultEngines?: string } } + * type EggConfig = PowerPartial + */ +export type PowerPartial = { + [U in keyof T]?: T[U] extends object + ? PowerPartial + : T[U]; +}; export default (appInfo: EggAppInfo) => { const config = {} as PowerPartial; @@ -12,4 +26,4 @@ export default (appInfo: EggAppInfo) => { }; return config; -} \ No newline at end of file +} diff --git a/test/fixtures/apps/whitelist-function/app/controller/upload.js b/test/fixtures/apps/whitelist-function/app/controller/upload.js index b396868..836f259 100644 --- a/test/fixtures/apps/whitelist-function/app/controller/upload.js +++ b/test/fixtures/apps/whitelist-function/app/controller/upload.js @@ -1,13 +1,11 @@ -'use strict'; - -const path = require('path'); -const fs = require('fs'); +const path = require('node:path'); +const fs = require('node:fs'); // keep one generator function test case -module.exports = function* () { +module.exports = async function() { const parts = this.multipart(); let part; - while ((part = yield parts()) != null) { + while ((part = await parts()) != null) { if (Array.isArray(part)) { continue; } else { diff --git a/test/fixtures/apps/whitelist-function/app/router.js b/test/fixtures/apps/whitelist-function/app/router.js index efcf28d..cc11e22 100644 --- a/test/fixtures/apps/whitelist-function/app/router.js +++ b/test/fixtures/apps/whitelist-function/app/router.js @@ -1,5 +1,3 @@ -'use strict'; - module.exports = app => { app.post('/upload.json', 'upload'); }; diff --git a/test/multipart-for-await.test.js b/test/multipart-for-await.test.ts similarity index 73% rename from test/multipart-for-await.test.js rename to test/multipart-for-await.test.ts index 152eadf..237885c 100644 --- a/test/multipart-for-await.test.js +++ b/test/multipart-for-await.test.ts @@ -1,17 +1,19 @@ -'use strict'; - -const assert = require('assert'); -const formstream = require('formstream'); -const urllib = require('urllib'); -const path = require('path'); -const mock = require('egg-mock'); - -describe('test/multipart-for-await.test.js', () => { - let app; - let server; - let host; +import assert from 'node:assert'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import formstream from 'formstream'; +import urllib from 'urllib'; +import { mm, MockApplication } from '@eggjs/mock'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('test/multipart-for-await.test.ts', () => { + let app: MockApplication; + let server: any; + let host: string; before(async () => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/multipart-for-await', }); await app.ready(); @@ -21,9 +23,9 @@ describe('test/multipart-for-await.test.js', () => { after(() => app.close()); after(() => server.close()); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(mm.restore); - it('should suport for-await-of', async () => { + it('should support for-await-of', async () => { const form = formstream(); form.field('foo', 'bar'); form.field('love', 'egg'); @@ -35,17 +37,17 @@ describe('test/multipart-for-await.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers: form.headers(), - stream: form, + stream: form as any, dataType: 'json', }); const data = res.data; // console.log(data); - assert(data.fields.foo === 'bar'); - assert(data.fields.love === 'egg'); - assert(data.files.file1.fileName === '中文名.js'); + assert.equal(data.fields.foo, 'bar'); + assert.equal(data.fields.love, 'egg'); + assert.equal(data.files.file1.fileName, '中文名.js'); assert(data.files.file1.content.includes('hello')); - assert(data.files.file2.fileName === 'testfile.js'); + assert.equal(data.files.file2.fileName, 'testfile.js'); assert(data.files.file2.content.includes('this is a test file')); assert(!data.files.file3); }); @@ -59,11 +61,11 @@ describe('test/multipart-for-await.test.js', () => { const res = await urllib.request(host + '/upload?mock_error=true', { method: 'POST', headers: form.headers(), - stream: form, + stream: form as any, dataType: 'json', }); - assert(res.data.message === 'mock error'); + assert.equal(res.data.message, 'mock error'); }); describe('should throw when limit', () => { @@ -77,13 +79,13 @@ describe('test/multipart-for-await.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers: form.headers(), - stream: form, + stream: form as any, dataType: 'json', }); const { data, status } = res; - assert(status === 413); - assert(data.message === 'Reach fileSize limit'); + assert.equal(status, 413); + assert.equal(data.message, 'Reach fileSize limit'); }); it('limit fileSize very small so limit event is miss', async () => { @@ -95,13 +97,13 @@ describe('test/multipart-for-await.test.js', () => { const res = await urllib.request(host + '/upload?fileSize=10', { method: 'POST', headers: form.headers(), - stream: form, + stream: form as any, dataType: 'json', }); const { data, status } = res; - assert(status === 413); - assert(data.message === 'Reach fileSize limit'); + assert.equal(status, 413); + assert.equal(data.message, 'Reach fileSize limit'); }); it('limit fieldSize', async () => { @@ -114,13 +116,13 @@ describe('test/multipart-for-await.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers: form.headers(), - stream: form, + stream: form as any, dataType: 'json', }); const { data, status } = res; - assert(status === 413); - assert(data.message === 'Reach fieldSize limit'); + assert.equal(status, 413); + assert.equal(data.message, 'Reach fieldSize limit'); }); // TODO: still not support at busboy 1.x (only support at urlencoded) @@ -136,13 +138,13 @@ describe('test/multipart-for-await.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers: form.headers(), - stream: form, + stream: form as any, dataType: 'json', }); const { data, status } = res; - assert(status === 413); - assert(data.message === 'Reach fieldNameSize limit'); + assert.equal(status, 413); + assert.equal(data.message, 'Reach fieldNameSize limit'); }); }); }); diff --git a/test/multipart.test.js b/test/multipart.test.ts similarity index 88% rename from test/multipart.test.js rename to test/multipart.test.ts index 72107d9..ebae4a4 100644 --- a/test/multipart.test.js +++ b/test/multipart.test.ts @@ -1,26 +1,22 @@ -const assert = require('node:assert'); -const { Agent } = require('node:http'); -const path = require('node:path'); -const fs = require('node:fs/promises'); -const formstream = require('formstream'); -const urllib = require('urllib'); -const mock = require('egg-mock'); - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -const agent = new Agent({ - keepAlive: true, -}); - -describe('test/multipart.test.js', () => { +import path from 'node:path'; +import fs from 'node:fs/promises'; +import assert from 'node:assert'; +import { fileURLToPath } from 'node:url'; +import { scheduler } from 'node:timers/promises'; +import formstream from 'formstream'; +import urllib from 'urllib'; +import { mm, MockApplication } from '@eggjs/mock'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('test/multipart.test.ts', () => { describe('multipart', () => { - let app; - let server; - let host; + let app: MockApplication; + let server: any; + let host: string; before(async () => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/multipart', }); await app.ready(); @@ -30,21 +26,21 @@ describe('test/multipart.test.js', () => { after(() => app.close()); after(() => server.close()); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(mm.restore); it('should not has clean_tmpdir schedule', async () => { try { await app.runSchedule('clean_tmpdir'); throw new Error('should not run this'); - } catch (err) { - assert(err.message === '[egg-schedule] Cannot find schedule clean_tmpdir'); + } catch (err: any) { + assert.equal(err.message, '[@eggjs/schedule] Cannot find schedule clean_tmpdir'); } }); it('should alway register clean_tmpdir schedule in stream mode', async () => { const logger = app.loggers.scheduleLogger; const content = await fs.readFile(logger.options.file, 'utf8'); - assert(/\[egg-schedule\]: register schedule .+clean_tmpdir\.js/.test(content)); + assert.match(content, /\[@eggjs\/schedule\]: register schedule .+clean_tmpdir\.ts/); }); it('should upload with csrf', async () => { @@ -58,12 +54,12 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); - assert(res.status === 200); + assert.equal(res.status, 200); const data = JSON.parse(res.data); - assert(data.filename === 'multipart.test.js'); + assert.equal(data.filename, 'multipart.test.ts'); }); it('should upload.json with ctoken', async () => { @@ -77,12 +73,12 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); - assert(res.status === 200); + assert.equal(res.status, 200); const data = JSON.parse(res.data); - assert(data.filename === 'multipart.test.js'); + assert.equal(data.filename, 'multipart.test.ts'); }); it('should handle unread stream and return error response', async () => { @@ -96,7 +92,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload?mock_stream_error=1', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert.match(res.data.toString(), /ENOENT:/); @@ -112,15 +108,14 @@ describe('test/multipart.test.js', () => { const result = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', - agent, }); assert(result.status === 500); const data = result.data; assert(data.message === 'part.foo is not a function'); - await sleep(100); + await scheduler.wait(100); } }); @@ -131,7 +126,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 400); @@ -147,7 +142,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -164,7 +159,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -179,7 +174,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -194,7 +189,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -209,7 +204,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -224,7 +219,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -253,11 +248,11 @@ describe('test/multipart.test.js', () => { }); describe('whitelist', () => { - let app; - let server; - let host; + let app: MockApplication; + let server: any; + let host: string; before(async () => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/multipart-with-whitelist', }); await app.ready(); @@ -267,7 +262,7 @@ describe('test/multipart.test.js', () => { after(() => app.close()); after(() => server.close()); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(mm.restore); it('should upload when extname speicified in whitelist', async () => { const form = formstream(); @@ -276,7 +271,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -291,7 +286,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -307,7 +302,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 400); @@ -317,11 +312,11 @@ describe('test/multipart.test.js', () => { }); describe('whitelist-function', () => { - let app; - let server; - let host; + let app: MockApplication; + let server: any; + let host: string; before(async () => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/whitelist-function', }); await app.ready(); @@ -331,7 +326,7 @@ describe('test/multipart.test.js', () => { after(() => app.close()); after(() => server.close()); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(mm.restore); it('should upload when extname pass whitelist function', async () => { const form = formstream(); @@ -340,7 +335,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -355,7 +350,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 400); @@ -370,7 +365,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(host + '/upload.json', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 400); @@ -380,11 +375,11 @@ describe('test/multipart.test.js', () => { }); describe('upload one file', () => { - let app; - let server; - let host; + let app: MockApplication; + let server: any; + let host: string; before(async () => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/upload-one-file', }); await app.ready(); @@ -399,7 +394,7 @@ describe('test/multipart.test.js', () => { after(() => app.close()); after(() => server.close()); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(mm.restore); it('should handle one upload file in simple way', async () => { const form = formstream(); @@ -411,7 +406,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); @@ -435,7 +430,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); @@ -457,7 +452,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); @@ -482,7 +477,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); @@ -499,7 +494,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 400); @@ -516,7 +511,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); @@ -537,7 +532,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 400); assert(res.data.toString().includes('Can\'t found upload file')); @@ -553,15 +548,14 @@ describe('test/multipart.test.js', () => { const result = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', - agent, }); assert(result.status === 500); const data = result.data; assert(data.message === 'stream.foo is not a function'); - await sleep(100); + await scheduler.wait(100); } }); @@ -573,9 +567,8 @@ describe('test/multipart.test.js', () => { const result = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', - agent, }); assert(result.status === 413); @@ -592,9 +585,8 @@ describe('test/multipart.test.js', () => { const result = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', - agent, }); assert(result.status === 413); @@ -604,12 +596,12 @@ describe('test/multipart.test.js', () => { }); describe('upload over fileSize limit', () => { - let app; - let server; - let host; + let app: MockApplication; + let server: any; + let host: string; const bigfile = path.join(__dirname, 'big.js'); before(async () => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/upload-limit', }); await app.ready(); @@ -626,7 +618,7 @@ describe('test/multipart.test.js', () => { await app.close(); }); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(mm.restore); it('should show error', async () => { const form = formstream(); @@ -638,7 +630,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); @@ -660,7 +652,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); @@ -682,7 +674,7 @@ describe('test/multipart.test.js', () => { const res = await urllib.request(url, { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); diff --git a/test/ts.test.js b/test/ts.test.js deleted file mode 100644 index 5b1cdd5..0000000 --- a/test/ts.test.js +++ /dev/null @@ -1,83 +0,0 @@ -const assert = require('assert'); -const formstream = require('formstream'); -const urllib = require('urllib'); -const path = require('path'); -const mock = require('egg-mock'); -const fs = require('fs/promises'); -const coffee = require('coffee'); - -describe('test/ts.test.js', () => { - it('should compile ts without err', () => { - return coffee.fork( - require.resolve('typescript/bin/tsc'), - [ '-p', path.resolve(__dirname, './fixtures/apps/ts/tsconfig.json') ] - ) - .debug() - .expect('code', 0) - .end(); - }); -}); - -describe('test/ts.test.js', () => { - let app; - let server; - let host; - before(() => { - app = mock.app({ - baseDir: 'apps/ts', - }); - return app.ready(); - }); - before(() => { - server = app.listen(); - host = 'http://127.0.0.1:' + server.address().port; - }); - after(() => { - return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true }); - }); - after(() => app.close()); - after(() => server.close()); - beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); - - it('ts should run without err', async () => { - const form = formstream(); - form.field('foo', 'bar').field('luckyscript', 'egg'); - form.file('file1', __filename, 'foooooooo.js'); - form.file('file2', __filename); - // will ignore empty file - form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); - form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); - // other form fields - form.field('work', 'with Node.js'); - - const headers = form.headers(); - const res = await urllib.request(host + '/', { - method: 'POST', - headers, - stream: form, - }); - - assert(res.status === 200); - const data = JSON.parse(res.data); - assert.deepStrictEqual(data.body, { foo: 'bar', luckyscript: 'egg', work: 'with Node.js' }); - assert(data.files.length === 3); - assert(data.files[0].field === 'file1'); - assert(data.files[0].filename === 'foooooooo.js'); - assert(data.files[0].encoding === '7bit'); - assert(data.files[0].mime === 'application/javascript'); - assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); - - assert(data.files[1].field === 'file2'); - assert(data.files[1].filename === 'ts.test.js'); - assert(data.files[1].encoding === '7bit'); - assert(data.files[1].mime === 'application/javascript'); - assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); - - assert(data.files[2].field === 'bigfile'); - assert(data.files[2].filename === 'bigfile.js'); - assert(data.files[2].encoding === '7bit'); - assert(data.files[2].mime === 'application/javascript'); - assert(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); - }); -}); diff --git a/test/ts.test.ts b/test/ts.test.ts new file mode 100644 index 0000000..5403b95 --- /dev/null +++ b/test/ts.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import { mm, MockApplication } from '@eggjs/mock'; +import formstream from 'formstream'; +import urllib from 'urllib'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('test/ts.test.ts', () => { + let app: MockApplication; + let server: any; + let host: string; + before(() => { + app = mm.app({ + baseDir: 'apps/ts', + }); + return app.ready(); + }); + before(() => { + server = app.listen(); + host = 'http://127.0.0.1:' + server.address().port; + }); + after(() => { + return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true }); + }); + after(() => app.close()); + after(() => server.close()); + beforeEach(() => app.mockCsrf()); + afterEach(mm.restore); + + it('ts should run without err', async () => { + const form = formstream(); + form.field('foo', 'bar').field('luckyscript', 'egg'); + form.file('file1', __filename, 'foooooooo.js'); + form.file('file2', __filename); + // will ignore empty file + form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); + form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); + // other form fields + form.field('work', 'with Node.js'); + + const headers = form.headers(); + const res = await urllib.request(host + '/', { + method: 'POST', + headers, + stream: form as any, + }); + + assert(res.status === 200); + const data = JSON.parse(res.data); + assert.deepStrictEqual(data.body, { foo: 'bar', luckyscript: 'egg', work: 'with Node.js' }); + assert.equal(data.files.length, 3); + assert.equal(data.files[0].field, 'file1'); + assert.equal(data.files[0].filename, 'foooooooo.js'); + assert.equal(data.files[0].encoding, '7bit'); + assert.equal(data.files[0].mime, 'application/javascript'); + assert.ok(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); + + assert.equal(data.files[1].field, 'file2'); + assert.equal(data.files[1].filename, 'ts.test.ts'); + assert.equal(data.files[1].encoding, '7bit'); + assert.equal(data.files[1].mime, 'video/mp2t'); + assert.ok(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); + + assert.equal(data.files[2].field, 'bigfile'); + assert.equal(data.files[2].filename, 'bigfile.js'); + assert.equal(data.files[2].encoding, '7bit'); + assert.equal(data.files[2].mime, 'application/javascript'); + assert.ok(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); + }); +}); From 2fac548cc53d720ee88e9944fb91c9c7ea2f237b Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Mon, 3 Feb 2025 22:29:15 +0800 Subject: [PATCH 3/4] f --- ...t.js => enable-pathToRegexpModule.test.ts} | 32 +++--- ...e-mode-limit-filesize-per-request.test.ts} | 32 +++--- ...> stream-mode-with-filematch-glob.test.ts} | 100 +++++++++--------- ....js => stream-mode-with-filematch.test.ts} | 48 +++++---- 4 files changed, 107 insertions(+), 105 deletions(-) rename test/{enable-pathToRegexpModule.test.js => enable-pathToRegexpModule.test.ts} (89%) rename test/{file-mode-limit-filesize-per-request.test.js => file-mode-limit-filesize-per-request.test.ts} (88%) rename test/{stream-mode-with-filematch-glob.test.js => stream-mode-with-filematch-glob.test.ts} (51%) rename test/{stream-mode-with-filematch.test.js => stream-mode-with-filematch.test.ts} (85%) diff --git a/test/enable-pathToRegexpModule.test.js b/test/enable-pathToRegexpModule.test.ts similarity index 89% rename from test/enable-pathToRegexpModule.test.js rename to test/enable-pathToRegexpModule.test.ts index afa864d..450f0fd 100644 --- a/test/enable-pathToRegexpModule.test.js +++ b/test/enable-pathToRegexpModule.test.ts @@ -1,18 +1,18 @@ -const assert = require('assert'); -const formstream = require('formstream'); -const urllib = require('urllib'); -const path = require('path'); -const mock = require('egg-mock'); -const fs = require('fs').promises; +import assert from 'node:assert'; +import formstream from 'formstream'; +import urllib from 'urllib'; +import path from 'node:path'; +import { mm, MockApplication } from '@eggjs/mock'; +import fs from 'node:fs/promises'; -describe('test/enable-pathToRegexpModule.test.js', () => { - let app; - let server; - let host; +describe.skip('test/enable-pathToRegexpModule.test.ts', () => { + let app: MockApplication; + let server: any; + let host: string; before(() => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/fileModeMatch-glob-with-pathToRegexpModule', - pathToRegexpModule: require.resolve('path-to-regexp-v8'), + // pathToRegexpModule: require.resolve('path-to-regexp-v8'), }); return app.ready(); }); @@ -26,7 +26,7 @@ describe('test/enable-pathToRegexpModule.test.js', () => { after(() => app.close()); after(() => server.close()); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(mm.restore); it('should upload match file mode work on /upload_file', async () => { const form = formstream(); @@ -43,7 +43,7 @@ describe('test/enable-pathToRegexpModule.test.js', () => { const res = await urllib.request(host + '/upload_file', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -84,7 +84,7 @@ describe('test/enable-pathToRegexpModule.test.js', () => { const res = await urllib.request(host + '/upload_file/foo', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert.equal(res.status, 200); @@ -125,7 +125,7 @@ describe('test/enable-pathToRegexpModule.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); diff --git a/test/file-mode-limit-filesize-per-request.test.js b/test/file-mode-limit-filesize-per-request.test.ts similarity index 88% rename from test/file-mode-limit-filesize-per-request.test.js rename to test/file-mode-limit-filesize-per-request.test.ts index 715eab1..325ede3 100644 --- a/test/file-mode-limit-filesize-per-request.test.js +++ b/test/file-mode-limit-filesize-per-request.test.ts @@ -1,17 +1,15 @@ -'use strict'; +import assert from 'node:assert'; +import fs from 'node:fs/promises'; +import formstream from 'formstream'; +import urllib from 'urllib'; +import { mm, MockApplication } from '@eggjs/mock'; -const assert = require('assert'); -const formstream = require('formstream'); -const urllib = require('urllib'); -const mock = require('egg-mock'); -const fs = require('fs').promises; - -describe('test/file-mode-limit-filesize-per-request.test.js', () => { - let app; - let server; - let host; +describe('test/file-mode-limit-filesize-per-request.test.ts', () => { + let app: MockApplication; + let server: any; + let host: string; before(() => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/limit-filesize-per-request', }); return app.ready(); @@ -26,7 +24,7 @@ describe('test/file-mode-limit-filesize-per-request.test.js', () => { after(() => app.close()); after(() => server.close()); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(mm.restore); it('should 200 when file size just 1mb on /upload-limit-1mb', async () => { const form = formstream(); @@ -36,7 +34,7 @@ describe('test/file-mode-limit-filesize-per-request.test.js', () => { const res = await urllib.request(host + '/upload-limit-1mb', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -60,7 +58,7 @@ describe('test/file-mode-limit-filesize-per-request.test.js', () => { const res = await urllib.request(host + '/upload-limit-1mb', { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); assert(res.status === 413); @@ -76,7 +74,7 @@ describe('test/file-mode-limit-filesize-per-request.test.js', () => { const res = await urllib.request(host + '/upload-limit-2mb', { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); @@ -101,7 +99,7 @@ describe('test/file-mode-limit-filesize-per-request.test.js', () => { const res = await urllib.request(host + '/upload-limit-2mb', { method: 'POST', headers, - stream: form, + stream: form as any, dataType: 'json', }); diff --git a/test/stream-mode-with-filematch-glob.test.js b/test/stream-mode-with-filematch-glob.test.ts similarity index 51% rename from test/stream-mode-with-filematch-glob.test.js rename to test/stream-mode-with-filematch-glob.test.ts index 4bda660..787db56 100644 --- a/test/stream-mode-with-filematch-glob.test.js +++ b/test/stream-mode-with-filematch-glob.test.ts @@ -1,18 +1,20 @@ -'use strict'; +import assert from 'node:assert'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { mm, MockApplication } from '@eggjs/mock'; +import formstream from 'formstream'; +import urllib from 'urllib'; -const assert = require('assert'); -const formstream = require('formstream'); -const urllib = require('urllib'); -const path = require('path'); -const mock = require('egg-mock'); -const fs = require('fs').promises; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); -describe('test/stream-mode-with-filematch-glob.test.js', () => { - let app; - let server; - let host; +describe('test/stream-mode-with-filematch-glob.test.ts', () => { + let app: MockApplication; + let server: any; + let host: string; before(() => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/fileModeMatch-glob', }); return app.ready(); @@ -27,7 +29,7 @@ describe('test/stream-mode-with-filematch-glob.test.js', () => { after(() => app.close()); after(() => server.close()); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(mm.restore); it('should upload match file mode work on /upload_file', async () => { const form = formstream(); @@ -44,30 +46,30 @@ describe('test/stream-mode-with-filematch-glob.test.js', () => { const res = await urllib.request(host + '/upload_file', { method: 'POST', headers, - stream: form, + stream: form as any, }); - assert(res.status === 200); + assert.equal(res.status, 200); const data = JSON.parse(res.data); assert.deepStrictEqual(data.body, { foo: 'fengmk2', love: 'egg', work: 'with Node.js' }); - assert(data.files.length === 3); - assert(data.files[0].field === 'file1'); - assert(data.files[0].filename === 'foooooooo.js'); - assert(data.files[0].encoding === '7bit'); - assert(data.files[0].mime === 'application/javascript'); - assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); + assert.equal(data.files.length, 3); + assert.equal(data.files[0].field, 'file1'); + assert.equal(data.files[0].filename, 'foooooooo.js'); + assert.equal(data.files[0].encoding, '7bit'); + assert.equal(data.files[0].mime, 'application/javascript'); + assert.ok(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); - assert(data.files[1].field === 'file2'); - assert(data.files[1].filename === 'stream-mode-with-filematch-glob.test.js'); - assert(data.files[1].encoding === '7bit'); - assert(data.files[1].mime === 'application/javascript'); - assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); + assert.equal(data.files[1].field, 'file2'); + assert.equal(data.files[1].filename, 'stream-mode-with-filematch-glob.test.ts'); + assert.equal(data.files[1].encoding, '7bit'); + assert.equal(data.files[1].mime, 'video/mp2t'); + assert.ok(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); - assert(data.files[2].field === 'bigfile'); - assert(data.files[2].filename === 'bigfile.js'); - assert(data.files[2].encoding === '7bit'); - assert(data.files[2].mime === 'application/javascript'); - assert(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); + assert.equal(data.files[2].field, 'bigfile'); + assert.equal(data.files[2].filename, 'bigfile.js'); + assert.equal(data.files[2].encoding, '7bit'); + assert.equal(data.files[2].mime, 'application/javascript'); + assert.ok(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); }); it('should upload match file mode work on /upload_file/*', async () => { @@ -85,30 +87,30 @@ describe('test/stream-mode-with-filematch-glob.test.js', () => { const res = await urllib.request(host + '/upload_file/foo', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert.equal(res.status, 200); const data = JSON.parse(res.data); assert.deepStrictEqual(data.body, { foo: 'fengmk2', love: 'egg', work: 'with Node.js' }); - assert(data.files.length === 3); - assert(data.files[0].field === 'file1'); - assert(data.files[0].filename === 'foooooooo.js'); - assert(data.files[0].encoding === '7bit'); - assert(data.files[0].mime === 'application/javascript'); - assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); + assert.equal(data.files.length, 3); + assert.equal(data.files[0].field, 'file1'); + assert.equal(data.files[0].filename, 'foooooooo.js'); + assert.equal(data.files[0].encoding, '7bit'); + assert.equal(data.files[0].mime, 'application/javascript'); + assert.ok(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); - assert(data.files[1].field === 'file2'); - assert(data.files[1].filename === 'stream-mode-with-filematch-glob.test.js'); - assert(data.files[1].encoding === '7bit'); - assert(data.files[1].mime === 'application/javascript'); - assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); + assert.equal(data.files[1].field, 'file2'); + assert.equal(data.files[1].filename, 'stream-mode-with-filematch-glob.test.ts'); + assert.equal(data.files[1].encoding, '7bit'); + assert.equal(data.files[1].mime, 'video/mp2t'); + assert.ok(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); - assert(data.files[2].field === 'bigfile'); - assert(data.files[2].filename === 'bigfile.js'); - assert(data.files[2].encoding === '7bit'); - assert(data.files[2].mime === 'application/javascript'); - assert(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); + assert.equal(data.files[2].field, 'bigfile'); + assert.equal(data.files[2].filename, 'bigfile.js'); + assert.equal(data.files[2].encoding, '7bit'); + assert.equal(data.files[2].mime, 'application/javascript'); + assert.ok(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); }); it('should upload not match file mode', async () => { @@ -126,7 +128,7 @@ describe('test/stream-mode-with-filematch-glob.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); diff --git a/test/stream-mode-with-filematch.test.js b/test/stream-mode-with-filematch.test.ts similarity index 85% rename from test/stream-mode-with-filematch.test.js rename to test/stream-mode-with-filematch.test.ts index 619fc4f..95624d5 100644 --- a/test/stream-mode-with-filematch.test.js +++ b/test/stream-mode-with-filematch.test.ts @@ -1,18 +1,20 @@ -'use strict'; - -const assert = require('assert'); -const formstream = require('formstream'); -const urllib = require('urllib'); -const path = require('path'); -const fs = require('fs').promises; -const mock = require('egg-mock'); - -describe('test/stream-mode-with-filematch.test.js', () => { - let app; - let server; - let host; +import assert from 'node:assert'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import formstream from 'formstream'; +import urllib from 'urllib'; +import { mm, MockApplication } from '@eggjs/mock'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('test/stream-mode-with-filematch.test.ts', () => { + let app: MockApplication; + let server: any; + let host: string; before(() => { - app = mock.app({ + app = mm.app({ baseDir: 'apps/fileModeMatch', }); return app.ready(); @@ -27,7 +29,7 @@ describe('test/stream-mode-with-filematch.test.js', () => { after(() => app.close()); after(() => server.close()); beforeEach(() => app.mockCsrf()); - afterEach(mock.restore); + afterEach(mm.restore); it('should upload match file mode', async () => { const form = formstream(); @@ -44,7 +46,7 @@ describe('test/stream-mode-with-filematch.test.js', () => { const res = await urllib.request(host + '/upload_file', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -58,9 +60,9 @@ describe('test/stream-mode-with-filematch.test.js', () => { assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); assert(data.files[1].field === 'file2'); - assert(data.files[1].filename === 'stream-mode-with-filematch.test.js'); + assert(data.files[1].filename === 'stream-mode-with-filematch.test.ts'); assert(data.files[1].encoding === '7bit'); - assert(data.files[1].mime === 'application/javascript'); + assert(data.files[1].mime === 'video/mp2t'); assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); assert(data.files[2].field === 'bigfile'); @@ -85,7 +87,7 @@ describe('test/stream-mode-with-filematch.test.js', () => { const res = await urllib.request(host + '/upload', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -108,7 +110,7 @@ describe('test/stream-mode-with-filematch.test.js', () => { const res = await urllib.request(host + '/save', { method: 'POST', headers, - stream: form, + stream: form as any, }); assert(res.status === 200); @@ -122,9 +124,9 @@ describe('test/stream-mode-with-filematch.test.js', () => { assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); assert(data.files[1].field === 'file2'); - assert(data.files[1].filename === 'stream-mode-with-filematch.test.js'); + assert(data.files[1].filename === 'stream-mode-with-filematch.test.ts'); assert(data.files[1].encoding === '7bit'); - assert(data.files[1].mime === 'application/javascript'); + assert(data.files[1].mime === 'video/mp2t'); assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); assert(data.files[2].field === 'bigfile'); @@ -150,6 +152,6 @@ describe('test/stream-mode-with-filematch.test.js', () => { // [egg-schedule]: register schedule /hello/egg-multipart/app/schedule/clean_tmpdir.js const logger = app.loggers.scheduleLogger; const content = await fs.readFile(logger.options.file, 'utf8'); - assert(/\[egg-schedule\]: register schedule .+clean_tmpdir\.js/.test(content)); + assert.match(content, /\[@eggjs\/schedule\]: register schedule .+clean_tmpdir\.ts/); }); }); From f9b5928f2ab41a678ffaff73f16f9194f94d783b Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Mon, 3 Feb 2025 22:33:46 +0800 Subject: [PATCH 4/4] f --- src/lib/utils.ts | 2 +- test/.setup.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 test/.setup.ts diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 504a94e..20ffae4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -15,7 +15,7 @@ export const whitelist = [ '.psd', // text '.svg', - '.js', '.jsx', '.ts', '.tsx', + '.js', '.jsx', '.json', '.css', '.less', '.html', '.htm', diff --git a/test/.setup.ts b/test/.setup.ts new file mode 100644 index 0000000..08d10df --- /dev/null +++ b/test/.setup.ts @@ -0,0 +1,4 @@ +import { whitelist } from '../src/lib/utils.js'; + +// add ts to whitelist for test +whitelist.push('.ts');