From 379106b4c3ed58926ecc81a38386a545a7d5cea1 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 30 Dec 2025 14:39:09 +0800 Subject: [PATCH 1/3] feat(co-busboy): port co-busboy from JavaScript to TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the co-busboy library as @eggjs/co-busboy with full TypeScript support: - Replace `chan` library with native Promise-based queue implementation - Add comprehensive type definitions for Parts, FileStream, FieldTuple - Support autoFields, checkField, and checkFile options - Handle gzip/deflate compression via inflation - Enforce limits with proper 413 error codes - Port all tests from Mocha to Vitest (22 tests passing) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/co-busboy/.gitignore | 4 + packages/co-busboy/CHANGELOG.md | 28 + packages/co-busboy/LICENSE | 21 + packages/co-busboy/README.md | 142 ++++++ packages/co-busboy/package.json | 56 ++ packages/co-busboy/src/index.ts | 373 ++++++++++++++ packages/co-busboy/test/index.test.ts | 706 ++++++++++++++++++++++++++ packages/co-busboy/tsconfig.json | 3 + packages/co-busboy/vitest.config.ts | 9 + pnpm-lock.yaml | 44 ++ pnpm-workspace.yaml | 4 + tsconfig.json | 3 + 12 files changed, 1393 insertions(+) create mode 100644 packages/co-busboy/.gitignore create mode 100644 packages/co-busboy/CHANGELOG.md create mode 100644 packages/co-busboy/LICENSE create mode 100644 packages/co-busboy/README.md create mode 100644 packages/co-busboy/package.json create mode 100644 packages/co-busboy/src/index.ts create mode 100644 packages/co-busboy/test/index.test.ts create mode 100644 packages/co-busboy/tsconfig.json create mode 100644 packages/co-busboy/vitest.config.ts diff --git a/packages/co-busboy/.gitignore b/packages/co-busboy/.gitignore new file mode 100644 index 0000000000..2c7f37b2a1 --- /dev/null +++ b/packages/co-busboy/.gitignore @@ -0,0 +1,4 @@ +node_modules +.tshy* +coverage +dist diff --git a/packages/co-busboy/CHANGELOG.md b/packages/co-busboy/CHANGELOG.md new file mode 100644 index 0000000000..d8a5c03612 --- /dev/null +++ b/packages/co-busboy/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +## 5.0.0-beta.36 + +**Initial TypeScript Release** + +This is a TypeScript port of [co-busboy](https://github.com/cojs/busboy) with the following improvements: + +- Full TypeScript support with comprehensive type definitions +- Modern async/await API (no generator dependencies) +- Replaced `chan` library with native Promise-based queue implementation +- ESM module format +- Compatible with Node.js >= 22.18.0 + +### Features + +- Parse multipart/form-data with async/await +- Support for both Node.js native requests and Koa context objects +- Auto-decompression of gzip/deflate compressed requests +- Field auto-collection with `autoFields` option +- Validation hooks: `checkField` and `checkFile` +- Limit enforcement (413 errors for parts/files/fields limits) + +### Breaking Changes from co-busboy 2.x + +- Requires Node.js >= 22.18.0 +- ESM only (no CommonJS support) +- Generator/yield syntax is no longer supported (use async/await) diff --git a/packages/co-busboy/LICENSE b/packages/co-busboy/LICENSE new file mode 100644 index 0000000000..7295685291 --- /dev/null +++ b/packages/co-busboy/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017-present Alibaba Group Holding Limited and other contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/co-busboy/README.md b/packages/co-busboy/README.md new file mode 100644 index 0000000000..3a791d1406 --- /dev/null +++ b/packages/co-busboy/README.md @@ -0,0 +1,142 @@ +# @eggjs/co-busboy + +Multipart form data handling with async/await support for Egg.js and Koa. + +A TypeScript port of [co-busboy](https://github.com/cojs/busboy), providing a promise-based wrapper around [busboy](https://github.com/mscdex/busboy) for parsing multipart/form-data. + +## Installation + +```bash +npm install @eggjs/co-busboy +``` + +## Usage + +```typescript +import { parse } from '@eggjs/co-busboy'; + +// In a Koa middleware +app.use(async (ctx) => { + const parts = parse(ctx, { autoFields: true }); + let part; + + while ((part = await parts())) { + if (Array.isArray(part)) { + // It's a field: [name, value, nameTruncated, valueTruncated] + console.log('Field:', part[0], '=', part[1]); + } else { + // It's a file stream with additional properties + console.log('File:', part.filename, part.mimeType); + // Consume the stream + part.pipe(fs.createWriteStream(`./uploads/${part.filename}`)); + } + } + + // Access auto-collected fields (when autoFields: true) + console.log(parts.field); // { fieldName: value } + console.log(parts.fields); // [[name, value, nameTrunc, valTrunc], ...] +}); +``` + +## API + +### `parse(request, options?)` + +Parse multipart form data from a request. + +#### Parameters + +- `request` - Node.js IncomingMessage or Koa context +- `options` - Optional configuration object + +#### Options + +All standard [busboy options](https://github.com/mscdex/busboy#api) are supported, plus: + +- `autoFields` (boolean, default: `false`) - When true, automatically collects all form fields. Fields will be available via `parts.field` (object lookup) and `parts.fields` (array lookup). Only file streams will be returned in the iteration. + +- `checkField` (function) - Hook to validate form fields. Return an Error to reject the field. + + ```typescript + checkField: (name, value, fieldnameTruncated, valueTruncated) => { + if (name === '_csrf' && !isValidToken(value)) { + return new Error('Invalid CSRF token'); + } + } + ``` + +- `checkFile` (function) - Hook to validate file uploads. Return an Error to reject the file. + ```typescript + checkFile: (fieldname, stream, filename, encoding, mimetype) => { + if (!filename.endsWith('.jpg')) { + const err = new Error('Only JPG files allowed'); + err.status = 400; + return err; + } + } + ``` + +#### Return Value + +Returns a `Parts` function that yields parts when called: + +```typescript +interface Parts { + (): Promise; + field: Record; + fields: FieldTuple[]; +} +``` + +- Call `parts()` repeatedly to get each part +- Returns `null` when parsing is complete +- When `autoFields` is true, fields are collected in `parts.field` and `parts.fields` + +#### Part Types + +**Field** - An array with 4 elements: + +```typescript +type FieldTuple = [ + string, // name + string, // value + boolean, // fieldnameTruncated + boolean // valueTruncated +]; +``` + +**File** - A Readable stream with additional properties: + +```typescript +interface FileStream extends Readable { + fieldname: string; + filename: string; + encoding: string; + transferEncoding: string; + mime: string; + mimeType: string; +} +``` + +## Error Handling + +Limit errors include status and code properties: + +```typescript +try { + while ((part = await parts())) { + // process part + } +} catch (err) { + console.log(err.status); // 413 + console.log(err.code); // 'Request_files_limit', 'Request_fields_limit', or 'Request_parts_limit' +} +``` + +## Compression Support + +Gzip and deflate compressed requests are automatically decompressed via the `inflation` library. + +## License + +[MIT](LICENSE) diff --git a/packages/co-busboy/package.json b/packages/co-busboy/package.json new file mode 100644 index 0000000000..b6b1c8b8d2 --- /dev/null +++ b/packages/co-busboy/package.json @@ -0,0 +1,56 @@ +{ + "name": "@eggjs/co-busboy", + "version": "5.0.0-beta.36", + "description": "co-busboy for egg - multipart form data handling with async/await support", + "keywords": [ + "busboy", + "egg", + "form-data", + "koa", + "multipart", + "upload" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/packages/co-busboy", + "license": "MIT", + "author": "eggjs", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/egg.git", + "directory": "packages/co-busboy" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "black-hole-stream": "catalog:", + "busboy": "catalog:", + "inflation": "catalog:" + }, + "devDependencies": { + "@eggjs/tsconfig": "workspace:*", + "@types/busboy": "catalog:", + "formstream": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/co-busboy/src/index.ts b/packages/co-busboy/src/index.ts new file mode 100644 index 0000000000..d382b45f87 --- /dev/null +++ b/packages/co-busboy/src/index.ts @@ -0,0 +1,373 @@ +import type { IncomingMessage } from 'node:http'; +import type { Readable } from 'node:stream'; +import { debuglog } from 'node:util'; + +// @ts-expect-error - no types available for black-hole-stream +import BlackHoleStream from 'black-hole-stream'; +import Busboy, { type BusboyConfig, type FieldInfo, type FileInfo } from 'busboy'; +// @ts-expect-error - no types available for inflation +import inflate from 'inflation'; + +const debug = debuglog('co-busboy'); + +const getDescriptor = Object.getOwnPropertyDescriptor; +const isArray = Array.isArray; + +/** + * Field tuple: [name, value, fieldnameTruncated, valueTruncated] + */ +export type FieldTuple = [string, string, boolean, boolean]; + +/** + * A file stream with additional properties attached + */ +export interface FileStream extends Readable { + fieldname: string; + filename: string; + encoding: string; + transferEncoding: string; + mime: string; + mimeType: string; +} + +/** + * A part can be either a field tuple or a file stream + */ +export type Part = FieldTuple | FileStream; + +/** + * Options for co-busboy, extends Busboy config + */ +export interface CoBusboyOptions extends Omit { + /** + * When true, automatically collects all form fields. + * Fields will be available via parts.field (object lookup) and parts.fields (array lookup). + * Only file streams will be returned in the iteration. + */ + autoFields?: boolean; + + /** + * Hook to validate form fields. + * Return an Error to reject the field. + * @param name - Field name + * @param value - Field value + * @param fieldnameTruncated - Whether the field name was truncated + * @param valueTruncated - Whether the value was truncated + */ + checkField?: ( + name: string, + value: string, + fieldnameTruncated: boolean, + valueTruncated: boolean, + ) => Error | undefined | void; + + /** + * Hook to validate file uploads. + * Return an Error to reject the file (the stream will be consumed and discarded). + * @param fieldname - Form field name + * @param stream - The file stream (note: not consumed yet when this is called) + * @param filename - Original uploaded filename + * @param encoding - Content transfer encoding + * @param mimetype - MIME type + */ + checkFile?: ( + fieldname: string, + stream: Readable, + filename: string, + encoding: string, + mimetype: string, + ) => Error | undefined | void; +} + +/** + * Custom error with status and code properties + */ +export interface CoBusboyError extends Error { + status?: number; + code?: string; +} + +/** + * The parts function returned by parse() + */ +export interface Parts { + (): Promise; + field: Record; + fields: FieldTuple[]; +} + +/** + * A request-like object (Node.js IncomingMessage or Koa context) + */ +export interface RequestLike { + req?: IncomingMessage; + headers?: IncomingMessage['headers']; + pipe?: IncomingMessage['pipe']; + on?: IncomingMessage['on']; + removeListener?: IncomingMessage['removeListener']; +} + +/** + * Promise-based queue for async iteration + * Replaces the chan library with a simpler, type-safe implementation + */ +class PromiseQueue { + private queue: (Part | Error | null)[] = []; + private pendingResolvers: Array<{ + resolve: (value: Part | null) => void; + reject: (error: Error) => void; + }> = []; + private finished = false; + + push(item: Part | Error | null): void { + if (this.finished) return; + + // If item is null, mark as finished + if (item === null) { + this.finished = true; + } + + const resolver = this.pendingResolvers.shift(); + if (resolver) { + if (item instanceof Error) { + resolver.reject(item); + } else { + resolver.resolve(item); + } + } else { + this.queue.push(item); + } + } + + pull(): Promise { + const item = this.queue.shift(); + if (item !== undefined) { + if (item instanceof Error) { + return Promise.reject(item); + } + return Promise.resolve(item); + } + + // If finished and queue is empty, return null + if (this.finished) { + return Promise.resolve(null); + } + + return new Promise((resolve, reject) => { + this.pendingResolvers.push({ resolve, reject }); + }); + } +} + +/** + * Parse multipart form data from a request. + * + * @param request - Node.js request or Koa context + * @param options - Busboy options with additional co-busboy options + * @returns A parts function that yields form parts + * + * @example + * ```typescript + * import { parse } from '@eggjs/co-busboy'; + * + * // In a Koa middleware + * app.use(async (ctx) => { + * const parts = parse(ctx, { autoFields: true }); + * let part; + * while ((part = await parts())) { + * if (Array.isArray(part)) { + * // It's a field: [name, value, nameTruncated, valueTruncated] + * console.log('Field:', part[0], '=', part[1]); + * } else { + * // It's a file stream + * console.log('File:', part.filename); + * part.resume(); // or pipe to a destination + * } + * } + * // Access auto-collected fields + * console.log(parts.field); // { fieldName: value } + * console.log(parts.fields); // [[name, value, ...], ...] + * }); + * ``` + */ +export function parse(request: RequestLike, options?: CoBusboyOptions): Parts { + const promiseQueue = new PromiseQueue(); + + const parts = function (): Promise { + return promiseQueue.pull(); + } as Parts; + + // Koa special sauce - extract underlying request if this is a Koa context + const req = (request.req || request) as IncomingMessage & { headers: IncomingMessage['headers'] }; + + const opts: CoBusboyOptions = options || {}; + const checkField = opts.checkField; + const checkFile = opts.checkFile; + let lastError: Error | undefined; + + // Create busboy with headers from the request + const busboyConfig: BusboyConfig = { + ...opts, + headers: req.headers, + }; + const busboy = Busboy(busboyConfig); + + // Handle compressed requests (gzip/deflate) + const inflatedRequest = inflate(req); + inflatedRequest.on('close', cleanup); + + busboy.on('field', onField).on('file', onFile).on('close', cleanup).on('error', onEnd).on('finish', onEnd); + + busboy.on('partsLimit', () => { + const err: CoBusboyError = new Error('Reach parts limit'); + err.code = 'Request_parts_limit'; + err.status = 413; + onError(err); + }); + + busboy.on('filesLimit', () => { + const err: CoBusboyError = new Error('Reach files limit'); + err.code = 'Request_files_limit'; + err.status = 413; + onError(err); + }); + + busboy.on('fieldsLimit', () => { + const err: CoBusboyError = new Error('Reach fields limit'); + err.code = 'Request_fields_limit'; + err.status = 413; + onError(err); + }); + + inflatedRequest.pipe(busboy); + + // Auto-fields mode: collect fields automatically + let field: Record | undefined; + let fields: FieldTuple[] | undefined; + if (opts.autoFields) { + field = parts.field = {}; + fields = parts.fields = []; + } else { + // Initialize even when autoFields is false for type safety + parts.field = {}; + parts.fields = []; + } + + return parts; + + function onField(name: string, val: string, info: FieldInfo): void { + const fieldnameTruncated = info.nameTruncated; + const valTruncated = info.valueTruncated; + + if (checkField) { + const err = checkField(name, val, fieldnameTruncated, valTruncated); + if (err) { + debug('onField error: %s', err); + return onError(err); + } + } + + const args: FieldTuple = [name, val, fieldnameTruncated, valTruncated]; + + if (opts.autoFields && field && fields) { + fields.push(args); + + // Don't overwrite prototypes + if (getDescriptor(Object.prototype, name)) return; + + const prev = field[name]; + if (prev == null) { + field[name] = val; + return; + } + if (isArray(prev)) { + prev.push(val); + return; + } + field[name] = [prev, val]; + } else { + promiseQueue.push(args); + } + } + + function onFile(fieldname: string, file: Readable, info: FileInfo): void { + function onFileError(err: Error): void { + debug('onFileError: %s', err); + lastError = err; + } + + function onFileCleanup(): void { + debug('onFileCleanup'); + file.removeListener('error', onFileError); + file.removeListener('end', onFileCleanup); + file.removeListener('close', onFileCleanup); + } + + file.on('error', onFileError); + file.on('end', onFileCleanup); + file.on('close', onFileCleanup); + + const { filename, encoding, mimeType } = info; + + if (checkFile) { + const err = checkFile(fieldname, file, filename, encoding, mimeType); + if (err) { + // Make sure request stream's data has been read + const blackHoleStream = new BlackHoleStream(); + file.pipe(blackHoleStream); + return onError(err); + } + } + + // Attach properties to the file stream for convenience + const fileStream = file as FileStream; + fileStream.fieldname = fieldname; + fileStream.filename = filename; + fileStream.transferEncoding = fileStream.encoding = encoding; + fileStream.mimeType = fileStream.mime = mimeType; + + promiseQueue.push(fileStream); + } + + function onError(err: Error): void { + debug('onError: %s', err); + lastError = err; + } + + function onEnd(err?: Error): void { + cleanup(); + debug('onEnd error: %s', err); + busboy.removeListener('finish', onEnd); + + // Remove error listener in next event loop, catch the 'Unexpected end of form' error in next tick + setImmediate(() => { + busboy.removeListener('error', onEnd); + }); + + // Ignore 'Unexpected end of form' if we already have an error + if (!lastError && err && err.message !== 'Unexpected end of form') { + lastError = err; + debug('set lastError'); + } + + // Push the error or null to signal completion + if (lastError) { + promiseQueue.push(lastError); + } else { + promiseQueue.push(null); + } + } + + function cleanup(): void { + debug('cleanup'); + // Keep finish listener to wait for all data flushed + // Keep error listener to wait for stream error + inflatedRequest.removeListener('close', cleanup); + busboy.removeListener('field', onField); + busboy.removeListener('file', onFile); + busboy.removeListener('close', cleanup); + } +} + +// Default export for convenience +export default parse; diff --git a/packages/co-busboy/test/index.test.ts b/packages/co-busboy/test/index.test.ts new file mode 100644 index 0000000000..ffd72a5a8d --- /dev/null +++ b/packages/co-busboy/test/index.test.ts @@ -0,0 +1,706 @@ +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import zlib from 'node:zlib'; + +import formstream from 'formstream'; +import { describe, it, beforeAll, afterAll } from 'vitest'; + +import { parse } from '../src/index.ts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +interface MockRequest { + headers: Record; + end: (data: string) => void; + pipe: (dest: NodeJS.WritableStream) => NodeJS.WritableStream; + on: (event: string, handler: (...args: unknown[]) => void) => MockRequest; + removeListener: (event: string, handler: (...args: unknown[]) => void) => MockRequest; +} + +function request(): MockRequest { + const stream = new PassThrough() as MockRequest & PassThrough; + + stream.headers = { + 'content-type': 'multipart/form-data; boundary=---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + }; + + stream.end( + [ + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super alpha file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super beta file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super gamma file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_1"', + '', + 'super gamma file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="_csrf"', + '', + 'ooxx', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="hasOwnProperty"', + '', + 'super bad file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'A'.repeat(1024), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="upload_file_1"; filename="1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + 'B'.repeat(1024), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="upload_file_2"; filename="hack.exe"', + 'Content-Type: application/octet-stream', + '', + 'A'.repeat(1024), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--', + ].join('\r\n'), + ); + + return stream as unknown as MockRequest; +} + +function gziped(): MockRequest & { pipe: (dest: NodeJS.WritableStream) => NodeJS.WritableStream } { + const stream = request(); + const oldHeaders = stream.headers; + const gzipStream = (stream as unknown as PassThrough).pipe(zlib.createGzip()) as unknown as MockRequest; + gzipStream.headers = oldHeaders; + gzipStream.headers['content-encoding'] = 'gzip'; + + return gzipStream as MockRequest & { pipe: (dest: NodeJS.WritableStream) => NodeJS.WritableStream }; +} + +function invalidRequest(): MockRequest { + const stream = new PassThrough() as MockRequest & PassThrough; + + stream.headers = { + 'content-type': 'multipart/form-data; boundary=---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + }; + + stream.end( + [ + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'A'.repeat(57), + '-----------------------------invalid', + 'Content-Disposition: form-data; name="upload_file_2"; filename="hack.exe"', + 'Content-Type: application/octet-stream', + '', + 'A'.repeat(57), + '-----------------------------invalid--', + ].join('\r\n'), + ); + + return stream as unknown as MockRequest; +} + +function malformatMultipart(): MockRequest { + const stream = new PassThrough() as MockRequest & PassThrough; + + stream.headers = { + 'content-type': 'multipart/form-data; boundary=test123', + }; + + stream.end( + '--test123\r\nContent-Disposition: form-data; name="file"; filename="test.txt"\r\nhello\r\n--test123--\r\n', + ); + + return stream as unknown as MockRequest; +} + +describe('Co Busboy', () => { + it('should work without autofields', async () => { + const parts = parse(request() as unknown as { headers: Record }); + let part; + let fields = 0; + let streams = 0; + + while ((part = await parts())) { + if (Array.isArray(part)) { + assert.strictEqual(part.length, 4); + fields++; + } else { + streams++; + part.resume(); + } + } + + assert.strictEqual(fields, 6); + assert.strictEqual(streams, 3); + }); + + it('should work without autofields on gziped content', async () => { + const parts = parse(gziped() as unknown as { headers: Record }); + let part; + let fields = 0; + let streams = 0; + + while ((part = await parts())) { + if (Array.isArray(part)) { + assert.strictEqual(part.length, 4); + fields++; + } else { + streams++; + part.resume(); + } + } + + assert.strictEqual(fields, 6); + assert.strictEqual(streams, 3); + }); + + it('should work with autofields', async () => { + const parts = parse(request() as unknown as { headers: Record }, { + autoFields: true, + }); + let part; + let fields = 0; + let streams = 0; + + while ((part = await parts())) { + if (Array.isArray(part)) { + fields++; + } else { + streams++; + part.resume(); + } + } + + assert.strictEqual(fields, 0); + assert.strictEqual(streams, 3); + assert.strictEqual(parts.fields.length, 6); + assert.strictEqual(Object.keys(parts.field).length, 3); + }); + + it('should work with autofields on gziped content', async () => { + const parts = parse(gziped() as unknown as { headers: Record }, { + autoFields: true, + }); + let part; + let fields = 0; + let streams = 0; + + while ((part = await parts())) { + if (Array.isArray(part)) { + fields++; + } else { + streams++; + part.resume(); + } + } + + assert.strictEqual(fields, 0); + assert.strictEqual(streams, 3); + assert.strictEqual(parts.fields.length, 6); + assert.strictEqual(Object.keys(parts.field).length, 3); + }); + + it('should work with autofields and arrays', async () => { + const parts = parse(request() as unknown as { headers: Record }, { + autoFields: true, + }); + let part; + + while ((part = await parts())) { + if (!Array.isArray(part)) { + part.resume(); + } + } + + assert.strictEqual(Object.keys(parts.field).length, 3); + assert.strictEqual((parts.field['file_name_0'] as string[]).length, 3); + assert.deepStrictEqual(parts.field['file_name_0'], ['super alpha file', 'super beta file', 'super gamma file']); + }); + + it('should work with delays', async () => { + const parts = parse(request() as unknown as { headers: Record }, { + autoFields: true, + }); + let part; + let streams = 0; + + while ((part = await parts())) { + if (!Array.isArray(part)) { + streams++; + part.resume(); + await wait(10); + } + } + + assert.strictEqual(streams, 3); + }); + + it('should not overwrite prototypes', async () => { + const parts = parse(request() as unknown as { headers: Record }, { + autoFields: true, + }); + let part; + + while ((part = await parts())) { + if (!Array.isArray(part)) { + part.resume(); + } + } + + assert.strictEqual(parts.field.hasOwnProperty, Object.prototype.hasOwnProperty); + }); + + it('should throw error when the files limit is reached', async () => { + const parts = parse(request() as unknown as { headers: Record }, { + limits: { + files: 1, + }, + }); + let part; + let error: (Error & { status?: number; code?: string }) | undefined; + + try { + while ((part = await parts())) { + if (!Array.isArray(part)) { + part.resume(); + } + } + } catch (e) { + error = e as Error & { status?: number; code?: string }; + } + + assert.strictEqual(error?.status, 413); + assert.strictEqual(error?.code, 'Request_files_limit'); + assert.strictEqual(error?.message, 'Reach files limit'); + }); + + it('should throw error when the fields limit is reached', async () => { + const parts = parse(request() as unknown as { headers: Record }, { + limits: { + fields: 1, + }, + }); + let part; + let error: (Error & { status?: number; code?: string }) | undefined; + + try { + while ((part = await parts())) { + if (!Array.isArray(part)) { + part.resume(); + } + } + } catch (e) { + error = e as Error & { status?: number; code?: string }; + } + + assert.strictEqual(error?.status, 413); + assert.strictEqual(error?.code, 'Request_fields_limit'); + assert.strictEqual(error?.message, 'Reach fields limit'); + }); + + it('should throw error when the parts limit is reached', async () => { + const parts = parse(request() as unknown as { headers: Record }, { + limits: { + parts: 1, + }, + }); + let part; + let error: (Error & { status?: number; code?: string }) | undefined; + + try { + while ((part = await parts())) { + if (!Array.isArray(part)) { + part.resume(); + } + } + } catch (e) { + error = e as Error & { status?: number; code?: string }; + } + + assert.strictEqual(error?.status, 413); + assert.strictEqual(error?.code, 'Request_parts_limit'); + assert.strictEqual(error?.message, 'Reach parts limit'); + }); + + it('should use options.checkField do csrf check', async () => { + const parts = parse(request() as unknown as { headers: Record }, { + checkField: (name, value) => { + if (name === '_csrf' && value !== 'pass') { + return new Error('invalid csrf token'); + } + }, + }); + let part; + + try { + while ((part = await parts())) { + if (Array.isArray(part)) { + assert.strictEqual(part.length, 4); + } else { + part.resume(); + } + } + throw new Error('should not run this'); + } catch (err) { + assert.strictEqual((err as Error).message, 'invalid csrf token'); + } + }); + + it('should use options.checkFile do filename extension check', async () => { + const parts = parse(request() as unknown as { headers: Record }, { + checkFile: (_fieldname, _filestream, filename) => { + if (path.extname(filename) !== '.dat') { + return new Error('invalid filename extension'); + } + }, + }); + let part; + + try { + while ((part = await parts())) { + if (Array.isArray(part)) { + assert.strictEqual(part.length, 4); + } else { + part.resume(); + } + } + throw new Error('should not run this'); + } catch (err) { + assert.strictEqual((err as Error).message, 'invalid filename extension'); + } + }); + + describe('checkFile()', () => { + const logfile = path.join(__dirname, 'test.log'); + + beforeAll(() => { + fs.writeFileSync(logfile, Buffer.alloc(1024 * 1024 * 10)); + }); + + afterAll(() => { + fs.unlinkSync(logfile); + }); + + it('should checkFile fail', async () => { + const form = formstream(); + + form.field('foo1', 'fengmk2').field('love', 'chair1'); + form.file('file', logfile); + form.field('foo2', 'fengmk2').field('love', 'chair2'); + + const headers = form.headers(); + (form as unknown as { headers: Record }).headers = { + ...headers, + 'content-type': headers['Content-Type'], + }; + + const parts = parse(form as unknown as { headers: Record }, { + checkFile: (_fieldname, _fileStream, filename) => { + const extname = filename && path.extname(filename); + if (!extname || ['.jpg', '.png'].indexOf(extname.toLowerCase()) === -1) { + const err = new Error('Invalid filename extension: ' + extname) as Error & { status?: number }; + err.status = 400; + return err; + } + }, + }); + + let part; + let fileCount = 0; + let fieldCount = 0; + let err: Error | undefined; + + while (true) { + try { + part = await parts(); + if (!part) { + break; + } + } catch (e) { + err = e as Error; + break; + } + + if (!Array.isArray(part)) { + fileCount++; + part.resume(); + } else { + fieldCount++; + } + } + + assert.strictEqual(fileCount, 0); + assert.strictEqual(fieldCount, 4); + assert.ok(err); + assert.strictEqual(err.message, 'Invalid filename extension: .log'); + }); + + it('should checkFile pass', async () => { + const form = formstream(); + + form.field('foo1', 'fengmk2').field('love', 'chair1'); + form.file('file', logfile); + form.field('foo2', 'fengmk2').field('love', 'chair2'); + + const headers = form.headers(); + (form as unknown as { headers: Record }).headers = { + ...headers, + 'content-type': headers['Content-Type'], + }; + + const parts = parse(form as unknown as { headers: Record }, { + checkFile: (_fieldname, _fileStream, filename) => { + const extname = filename && path.extname(filename); + if (!extname || ['.jpg', '.png', '.log'].indexOf(extname.toLowerCase()) === -1) { + const err = new Error('Invalid filename extension: ' + extname) as Error & { status?: number }; + err.status = 400; + return err; + } + }, + }); + + let part; + let fileCount = 0; + let fieldCount = 0; + let err: Error | undefined; + + while (true) { + try { + part = await parts(); + if (!part) { + break; + } + } catch (e) { + err = e as Error; + break; + } + + if (!Array.isArray(part)) { + fileCount++; + part.resume(); + } else { + fieldCount++; + } + } + + assert.strictEqual(fileCount, 1); + assert.strictEqual(fieldCount, 4); + assert.ok(!err); + }); + }); + + describe('with promise', () => { + it('should work without autofields', async () => { + const parts = parse(request() as unknown as { headers: Record }); + let promise; + let part; + let fields = 0; + let streams = 0; + + while (((promise = parts()), (part = await promise))) { + assert.ok(promise instanceof Promise); + if (Array.isArray(part)) { + assert.strictEqual(part.length, 4); + fields++; + } else { + streams++; + part.resume(); + } + } + + assert.strictEqual(fields, 6); + assert.strictEqual(streams, 3); + }); + + it('should work without autofields on gziped content', async () => { + const parts = parse(gziped() as unknown as { headers: Record }); + let promise; + let part; + let fields = 0; + let streams = 0; + + while (((promise = parts()), (part = await promise))) { + assert.ok(promise instanceof Promise); + if (Array.isArray(part)) { + assert.strictEqual(part.length, 4); + fields++; + } else { + streams++; + part.resume(); + } + } + + assert.strictEqual(fields, 6); + assert.strictEqual(streams, 3); + }); + + it('should work with autofields', async () => { + const parts = parse(request() as unknown as { headers: Record }, { + autoFields: true, + }); + let promise; + let part; + let fields = 0; + let streams = 0; + + while (((promise = parts()), (part = await promise))) { + assert.ok(promise instanceof Promise); + if (Array.isArray(part)) { + fields++; + } else { + streams++; + part.resume(); + } + } + + assert.strictEqual(fields, 0); + assert.strictEqual(streams, 3); + assert.strictEqual(parts.fields.length, 6); + assert.strictEqual(Object.keys(parts.field).length, 3); + }); + + it('should work with autofields on gziped content', async () => { + const parts = parse(gziped() as unknown as { headers: Record }, { + autoFields: true, + }); + let promise; + let part; + let fields = 0; + let streams = 0; + + while (((promise = parts()), (part = await promise))) { + assert.ok(promise instanceof Promise); + if (Array.isArray(part)) { + fields++; + } else { + streams++; + part.resume(); + } + } + + assert.strictEqual(fields, 0); + assert.strictEqual(streams, 3); + assert.strictEqual(parts.fields.length, 6); + assert.strictEqual(Object.keys(parts.field).length, 3); + }); + }); + + describe('with wrong encoding', () => { + it('will get nothing if set wrong encoding on gziped content', async () => { + const stream = gziped(); + delete (stream.headers as { 'content-encoding'?: string })['content-encoding']; + + const parts = parse(stream as unknown as { headers: Record }, { + autoFields: true, + }); + let promise; + let part; + let fields = 0; + let streams = 0; + + while (((promise = parts()), (part = await promise))) { + assert.ok(promise instanceof Promise); + if (Array.isArray(part)) { + fields++; + } else { + streams++; + part.resume(); + } + } + + assert.strictEqual(fields, 0); + assert.strictEqual(streams, 0); + assert.strictEqual(parts.fields.length, 0); + assert.strictEqual(Object.keys(parts.field).length, 0); + }); + }); + + describe('invalid multipart', () => { + it('should handle error: Unexpected end of form', async () => { + const parts = parse(invalidRequest() as unknown as { headers: Record }); + let part; + + try { + while ((part = await parts())) { + if (!Array.isArray(part)) { + part.resume(); + } + } + + throw new Error('should not run this'); + } catch (err) { + assert.strictEqual((err as Error).message, 'Unexpected end of form'); + } + }); + + it('should handle error: Unexpected end of form with checkFile', async () => { + const parts = parse(invalidRequest() as unknown as { headers: Record }, { + checkFile: () => { + return new Error('invalid filename extension'); + }, + }); + let part; + + try { + while ((part = await parts())) { + if (!Array.isArray(part)) { + part.resume(); + } + } + + throw new Error('should not run this'); + } catch (err) { + assert.strictEqual((err as Error).message, 'Unexpected end of form'); + } + }); + }); + + describe('with malformat multipart', () => { + it('will get nothing if receive malformat multipart', async () => { + const stream = malformatMultipart(); + const parts = parse(stream as unknown as { headers: Record }, { + autoFields: true, + }); + let promise; + let part; + let fields = 0; + let streams = 0; + + try { + while (((promise = parts()), (part = await promise))) { + assert.ok(promise instanceof Promise); + if (Array.isArray(part)) { + fields++; + } else { + streams++; + part.resume(); + } + } + } catch (err) { + assert.strictEqual((err as Error).message, 'Malformed part header'); + } + + assert.strictEqual(fields, 0); + assert.strictEqual(streams, 0); + assert.strictEqual(parts.fields.length, 0); + assert.strictEqual(Object.keys(parts.field).length, 0); + }); + }); +}); diff --git a/packages/co-busboy/tsconfig.json b/packages/co-busboy/tsconfig.json new file mode 100644 index 0000000000..4082f16a5d --- /dev/null +++ b/packages/co-busboy/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/co-busboy/vitest.config.ts b/packages/co-busboy/vitest.config.ts new file mode 100644 index 0000000000..2b417434e4 --- /dev/null +++ b/packages/co-busboy/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + testTimeout: 15000, + include: ['test/**/*.test.ts'], + exclude: ['**/test/fixtures/**', '**/node_modules/**', '**/dist/**'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90f3cbb6c5..ba8bed2a61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ catalogs: '@types/body-parser': specifier: ^1.19.5 version: 1.19.6 + '@types/busboy': + specifier: ^1.5.4 + version: 1.5.4 '@types/bytes': specifier: ^3.1.5 version: 3.1.5 @@ -189,9 +192,15 @@ catalogs: benchmark: specifier: ^2.1.4 version: 2.1.4 + black-hole-stream: + specifier: ^0.0.1 + version: 0.0.1 body-parser: specifier: ^2.0.0 version: 2.2.0 + busboy: + specifier: ^1.6.0 + version: 1.6.0 bytes: specifier: ^3.1.2 version: 3.1.2 @@ -339,6 +348,9 @@ catalogs: husky: specifier: ^9.1.7 version: 9.1.7 + inflation: + specifier: ^2.1.0 + version: 2.1.0 inflection: specifier: ^3.0.0 version: 3.0.2 @@ -806,6 +818,31 @@ importers: specifier: 'catalog:' version: 4.0.15(@types/node@24.10.2)(@vitest/ui@4.0.15)(esbuild@0.27.0)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2) + packages/co-busboy: + dependencies: + black-hole-stream: + specifier: 'catalog:' + version: 0.0.1 + busboy: + specifier: 'catalog:' + version: 1.6.0 + inflation: + specifier: 'catalog:' + version: 2.1.0 + devDependencies: + '@eggjs/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@types/busboy': + specifier: 'catalog:' + version: 1.5.4 + formstream: + specifier: 'catalog:' + version: 1.5.2 + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/cookies: dependencies: should-send-same-site-none: @@ -4700,6 +4737,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/busboy@1.5.4': + resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==} + '@types/bytes@3.1.5': resolution: {integrity: sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ==} @@ -9925,6 +9965,10 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 24.10.2 + '@types/busboy@1.5.4': + dependencies: + '@types/node': 24.10.2 + '@types/bytes@3.1.5': {} '@types/chai@5.2.3': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a4614177af..451be2099f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,7 @@ catalog: '@swc/core': ^1.15.1 '@types/accepts': ^1.3.7 '@types/body-parser': ^1.19.5 + '@types/busboy': ^1.5.4 '@types/bytes': ^3.1.5 '@types/common-tags': ^1.8.4 '@types/content-disposition': ^0.5.8 @@ -77,7 +78,9 @@ catalog: await-first: ^1.0.0 beautify-benchmark: ^0.2.4 benchmark: ^2.1.4 + black-hole-stream: ^0.0.1 body-parser: ^2.0.0 + busboy: ^1.6.0 bytes: ^3.1.2 c8: ^10.1.3 cache-content-type: ^2.0.0 @@ -131,6 +134,7 @@ catalog: humanize-ms: ^2.0.0 husky: ^9.1.7 inflection: ^3.0.0 + inflation: ^2.1.0 ini: ^6.0.0 ioredis: ^5.4.2 is-type-of: ^2.2.0 diff --git a/tsconfig.json b/tsconfig.json index 14e5ac8d41..3e00668558 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,9 @@ { "path": "./packages/cluster" }, + { + "path": "./packages/co-busboy" + }, { "path": "./packages/koa" }, From f5d9c224b2e6d4dc2f8cd73beb4494b5432d2b27 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 30 Dec 2025 14:47:18 +0800 Subject: [PATCH 2/3] refactor(multipart): replace co-busboy with @eggjs/co-busboy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update multipart plugin to use the new @eggjs/co-busboy package - Import Part type for proper type narrowing - Add truncated property to FileStream interface - Fix type assertions for AsyncIterable return 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/co-busboy/CHANGELOG.md | 2 +- packages/co-busboy/package.json | 2 +- packages/co-busboy/src/index.ts | 8 +++++++ plugins/multipart/package.json | 2 +- plugins/multipart/src/app/extend/context.ts | 11 +++++---- pnpm-lock.yaml | 25 +++------------------ 6 files changed, 19 insertions(+), 31 deletions(-) diff --git a/packages/co-busboy/CHANGELOG.md b/packages/co-busboy/CHANGELOG.md index d8a5c03612..2b33a559ef 100644 --- a/packages/co-busboy/CHANGELOG.md +++ b/packages/co-busboy/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 5.0.0-beta.36 +## 4.0.0-beta.36 **Initial TypeScript Release** diff --git a/packages/co-busboy/package.json b/packages/co-busboy/package.json index b6b1c8b8d2..7ad8c041ca 100644 --- a/packages/co-busboy/package.json +++ b/packages/co-busboy/package.json @@ -1,6 +1,6 @@ { "name": "@eggjs/co-busboy", - "version": "5.0.0-beta.36", + "version": "4.0.0-beta.36", "description": "co-busboy for egg - multipart form data handling with async/await support", "keywords": [ "busboy", diff --git a/packages/co-busboy/src/index.ts b/packages/co-busboy/src/index.ts index d382b45f87..2768d764ed 100644 --- a/packages/co-busboy/src/index.ts +++ b/packages/co-busboy/src/index.ts @@ -28,6 +28,10 @@ export interface FileStream extends Readable { transferEncoding: string; mime: string; mimeType: string; + /** + * Set by busboy when file size limit is reached + */ + truncated?: boolean; } /** @@ -94,6 +98,10 @@ export interface Parts { (): Promise; field: Record; fields: FieldTuple[]; + /** + * Allow adding async iterator at runtime for for-await-of support + */ + [Symbol.asyncIterator]?: () => AsyncIterator; } /** diff --git a/plugins/multipart/package.json b/plugins/multipart/package.json index 1b1946e80d..908f49e91e 100644 --- a/plugins/multipart/package.json +++ b/plugins/multipart/package.json @@ -59,9 +59,9 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@eggjs/co-busboy": "workspace:*", "@eggjs/path-matching": "workspace:*", "bytes": "catalog:", - "co-busboy": "catalog:", "dayjs": "catalog:" }, "devDependencies": { diff --git a/plugins/multipart/src/app/extend/context.ts b/plugins/multipart/src/app/extend/context.ts index 45cf0da8dd..b631c417b2 100644 --- a/plugins/multipart/src/app/extend/context.ts +++ b/plugins/multipart/src/app/extend/context.ts @@ -6,8 +6,7 @@ import path from 'node:path'; import { Readable, PassThrough } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -// @ts-expect-error no types -import parse from 'co-busboy'; +import { parse, type Part } from '@eggjs/co-busboy'; import dayjs from 'dayjs'; import { Context } from 'egg'; @@ -120,7 +119,7 @@ export default class MultipartContext extends Context { // mount asyncIterator, so we can use `for await` to get parts const parts = parse(this, parseOptions); parts[Symbol.asyncIterator] = async function* () { - let part: MultipartFileStream | undefined; + let part: Part | null; do { part = await parts(); @@ -157,10 +156,10 @@ export default class MultipartContext extends Context { } // dispatch part to outter logic such as for-await-of - yield part; - } while (part !== undefined); + yield part as MultipartFileStream; + } while (part !== null); }; - return parts; + return parts as unknown as AsyncIterable; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba8bed2a61..a893455b7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,9 +231,6 @@ catalogs: cluster-reload: specifier: ^2.0.0 version: 2.0.0 - co-busboy: - specifier: ^2.0.1 - version: 2.0.2 coffee: specifier: '5' version: 5.5.1 @@ -1605,15 +1602,15 @@ importers: plugins/multipart: dependencies: + '@eggjs/co-busboy': + specifier: workspace:* + version: link:../../packages/co-busboy '@eggjs/path-matching': specifier: workspace:* version: link:../../packages/path-matching bytes: specifier: 'catalog:' version: 3.1.2 - co-busboy: - specifier: 'catalog:' - version: 2.0.2 dayjs: specifier: 'catalog:' version: 1.11.18 @@ -5484,9 +5481,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chan@0.6.1: - resolution: {integrity: sha512-/TdBP2UhbBmw7qnqkzo9Mk4rzvwRv4dlNPXFerqWy90T8oBspKagJNZxrDbExKHhx9uXXHjo3f9mHgs9iKO3nQ==} - change-case@3.1.0: resolution: {integrity: sha512-2AZp7uJZbYEzRPsFoa+ijKdvp9zsrnnt6+yFokfwEpeJm0xuJDVoxiRCAaTzyJND8GJkofo2IcKWaUZ/OECVzw==} @@ -5588,10 +5582,6 @@ packages: resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==} engines: {node: '>=8.0.0'} - co-busboy@2.0.2: - resolution: {integrity: sha512-xntc6PdplMQ9M92T5aKWRu8OBh4CbQcLHPnf2K3c+rD2xhAJcIjC5liblUquxDGTErsnHadMOsCcd6xhnpPAFA==} - engines: {node: '>= 14.0.0'} - co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -10766,8 +10756,6 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chan@0.6.1: {} - change-case@3.1.0: dependencies: camel-case: 3.0.0 @@ -10911,13 +10899,6 @@ snapshots: raw-body: 2.5.2 type-is: 1.6.18 - co-busboy@2.0.2: - dependencies: - black-hole-stream: 0.0.1 - busboy: 1.6.0 - chan: 0.6.1 - inflation: 2.1.0 - co@4.6.0: {} coffee@5.5.1: From fa792f310b0a631f653d15020233fe6bf586a28c Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 30 Dec 2025 15:10:48 +0800 Subject: [PATCH 3/3] FIXUP --- .github/workflows/ci.yml | 9 +++++++-- package.json | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61ef1bbc5a..f444bee7e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,8 +158,13 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run tests - run: pnpm run ci --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + - name: Run tests with coverage + if: ${{ matrix.os != 'windows-latest' }} + run: pnpm run test:cov --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + + - name: Run tests without coverage + if: ${{ matrix.os == 'windows-latest' }} + run: pnpm run test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - name: Run example tests if: ${{ matrix.node != '20' && matrix.os != 'windows-latest' }} diff --git a/package.json b/package.json index 3c58f97f92..4db1c0b29d 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "fmt": "oxfmt", "typecheck": "pnpm clean && pnpm -r run typecheck", "fmtcheck": "oxfmt --check .", - "pretest": "pnpm run clean && pnpm -r run pretest", + "pretest": "pnpm run clean && pnpm -r --parallel run pretest", "test": "vitest run --bail 1 --retry 2 --testTimeout 20000 --hookTimeout 20000", "test:cov": "pnpm run test --coverage", "preci": "pnpm -r --parallel run pretest",