初次提交

This commit is contained in:
2022-04-02 16:17:29 +08:00
commit 15e175f061
8 changed files with 589 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/node_modules
/dist
/package-lock.json
/typing

2
.npmignore Normal file
View File

@ -0,0 +1,2 @@
/src
/tsconfig.json

23
README.md Normal file
View File

@ -0,0 +1,23 @@
# yizhi-multipart-reader
一个 没有任何依赖的 `multipart/form-data` 内容读取工具
## 简单用例
```typescript
import { MultipartReader, File } from 'yizhi-multipart-reader'
const reader = new MultipartReader({
//请求类型为http.IncomingMessage
req: ctx.req,
//formdata分隔符从headers['content-type']中取得
boundary: 'boundary from content-type',
//文件保存位置
saveDir: '/path/to/savedir',
})
const { fields, files } = await reader.wait()
```

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "yizhi-multipart-reader",
"version": "1.0.2",
"description": "",
"main": "dist/index.js",
"types": "typing/index.d.ts",
"scripts": {
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/node": "^17.0.23",
"typescript": "^4.6.3"
}
}

85
src/file.ts Normal file
View File

@ -0,0 +1,85 @@
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
interface IFileConstructorOption {
name: string
type: string
saveDir: string
}
/**
* 表单提交的文件
*/
export class File {
/** 文件名 */
public readonly name: string
/** mime类型 */
public readonly type: string
/** 文件保存路径 */
public path: string
#ws: fs.WriteStream
#hash: crypto.Hash
#hashstr!: string
#finished = false
#size = 0
#top64 = Buffer.from('')
constructor(option: IFileConstructorOption) {
this.name = option.name
this.type = option.type
const randomText = parseInt(Math.random() * 10000 as any).toString().padStart(4, '0')
this.path = path.join(option.saveDir, `${Date.now()}${randomText}`)
this.#ws = fs.createWriteStream(this.path)
this.#hash = crypto.createHash('md5')
}
/**
* 写入数据到文件
* @param data 写入的数据
*/
public async write(data: Buffer) {
if (this.#finished || !this.#ws.writable) return
//开始64字节
if (this.#top64.length < 64) {
this.#top64 = Buffer.concat([
this.#top64,
data.slice(0, 64 - this.#top64.length)
])
}
this.#size += data.length
//写入
await Promise.all([
new Promise<void>((resolve, reject) => this.#ws.write(data, err => err ? reject(err) : resolve())),
new Promise<void>((resolve, reject) => this.#hash.write(data, err => err ? reject(err) : resolve())),
])
}
/**
* 结束
*/
public finish() {
this.#ws.destroy()
this.#hashstr = this.#hash.digest('hex')
this.#hash.destroy()
this.#finished = true
}
/** 文件hash */
public get hash() {
return this.#hashstr
}
/** 文件大小 */
public get size() {
return this.#size
}
/** 文件开始64字节 */
public get top64() {
return this.#top64
}
}

2
src/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './file'
export * from './reader'

356
src/reader.ts Normal file
View File

@ -0,0 +1,356 @@
import type http from 'http'
import { File } from './file'
enum ReadState {
boundary = 1, //需要读取boundary
header = 2, //需要读取header
content = 3, //需要读取内容
finish = 4, //结束
}
//字段头
interface IFormDataHeaderField {
/** 头类型 */
type: 'field'
/** 表单name */
name: string
/** 字段内容 */
content: string
}
//文件头
interface IFormDataHeaderFile {
/** 头类型 */
type: 'file'
/** 表单name */
name: string
/** 文件 */
file: File
}
//整合后的头
type IFormDataHeader = IFormDataHeaderField | IFormDataHeaderFile
export interface IMultipartReaderResult {
/** 文件列表 */
files: { [k in string]: File | Array<File> }
/** 表单字段列表 */
fields: { [k in string]: string | Array<string> }
}
export interface IMultipartReaderConstructorOption {
/** 请求 */
req: http.IncomingMessage
/** 分隔符 */
boundary: string
/** 文件保存目录 */
saveDir: string
}
interface IReadResult {
left: Buffer | null
exit: boolean
}
export class MultipartReader {
/** 分隔符 */
#boundary: Buffer
/** 保存目录 */
#saveDir: string
/** 当前处理状态 */
#state = ReadState.boundary
/** 上一次没有处理完的头数据 */
#headBuffer: Buffer | null = null
/** 当前头信息 */
#header: IFormDataHeader | undefined = undefined
/** 处理到的字段信息 */
#fields: IMultipartReaderResult['fields'] = {}
/** 处理到的文件信息 */
#files: IMultipartReaderResult['files'] = {}
/** 已读取的长度 */
#readed = 0
/** 进度监听 */
#onReadCallback?: (readed: number) => any
//wait回调
#resolve?: (data: IMultipartReaderResult) => any
#reject?: (err: Error) => any
//======================================================公共函数======================================================
/**
* multipart读取工具
* @param option 选项
*/
constructor(option: IMultipartReaderConstructorOption) {
this.#boundary = Buffer.from(option.boundary)
this.#saveDir = option.saveDir
this.#handleRequest(option.req)
}
/**
* 等待处理,处理完成得到内容
*/
public async wait() {
return new Promise<IMultipartReaderResult>((resolve, reject) => {
this.#resolve = resolve
this.#reject = reject
})
}
/**
* 监听读取过程
* @param callback 回调函数
*/
public onRead(callback: (readed: number) => any) {
this.#onReadCallback = callback
}
//======================================================私有函数======================================================
//处理请求
#handleRequest(req: http.IncomingMessage) {
let old: Buffer | null = null //处理剩下的Buffer
const dataReader = async (_data: Buffer) => {
//暂停读取
req.pause()
//数据内容
const data: Buffer = old ? Buffer.concat([old, _data]) : _data
old = await this.#parseData(data)
//触发一下监听过程
this.#onReadCallback?.(this.#readed += _data.length)
//处理数据
req.resume()
if (this.#state == ReadState.finish) {
this.#resolve?.({ files: this.#files, fields: this.#fields })
}
}
const onError = (err: Error) => {
this.#reject?.(err)
}
//事件处理
req.on('error', onError)
req.on('data', dataReader)
req.once('end', () => {
//删除监听器
req.removeListener('error', onError)
req.removeListener('data', dataReader)
})
}
//定义一个函数来处理data
async #parseData(data: Buffer): Promise<Buffer | null> {
let result: IReadResult = { exit: false, left: data }
//使用循环,以避免使用递归调用
while (result.left && !result.exit) {
switch (this.#state) {
//读取boundary
//formdata中的每个数据是由 Content-Type中指定的Boundary进行分割的
//内容的分隔符中会在boundary前加上--,如果是结束,末尾还有--
case ReadState.boundary:
result = await this.#readBoundary(result.left)
break
//读取头
//头部以两个连续的换行为结束
case ReadState.header:
result = await this.#readHeader(result.left)
break
//读取内容
//内容可以一直读取,直到遇到下一个分隔符为止
case ReadState.content:
result = await this.#readContent(result.left)
break
//已经读完,其他的就不管了
case ReadState.finish:
result = { exit: true, left: null }
break
}
}
//返回剩余内容
return result?.left ?? null
}
//读取boundary一开始会读取boundary内容读取完后也会回来继续读取boundary
async #readBoundary(data: Buffer): Promise<IReadResult> {
//如果数据量不够,剩下的数据下次处理
if (data.length <= this.#boundary.length + 2) return { left: data, exit: true } //+2是因为boundary开始有 --
//开始有 --
if (data[0] == 45 && data[1] == 45) data = data.slice(2)
else throw new Error('multipart body error')
//去除boundary
if (this.#boundary.compare(data, 0, this.#boundary.length) == 0) data = data.slice(this.#boundary.length)
else throw new Error('multipart body error')
//去除\r\n
if (data[0] == 13) {
data = data.slice(1)
if (data[0] == 10) data = data.slice(1) // \r
else throw new Error('multipart body error') // \r后面必须时\n
}
//去除\n
else if (data[0] == 10) data = data.slice(1)
//遇到 -- 可能就要结束了
else if (data[0] == 45 && data[1] == 45) {
//看看是否有换行,有换行表示结束
if (data[2] == 10 || (data[2] == 13 && data[3] == 10)) {
this.#saveHeader()
this.#state = ReadState.finish
return { left: null, exit: true }
}
//否则就当出错
else throw new Error('multipart body error')
}
//上面的情况都不是,表示出错了
else throw new Error('multipart body error')
//开始读取头
this.#state = ReadState.header
//返回剩下的数据
return { left: data, exit: false }
}
//读取头信息当度去玩boundary后就应该读取头
async #readHeader(data: Buffer): Promise<IReadResult> {
//读取头结束位置
let endAt = 0
for (let i = 0; i < data.length; ++i) {
// 检测是不是两个连续换行
if (data[i] == 10) {
if (data[i + 1] == 10) {
endAt = i + 1
break
}
else if (data[i + 1] == 13 && data[i + 2] == 10) {
endAt = i + 2
break
}
}
}
//如果头读取完成则读取body
if (endAt) {
//取得头
const header = this.#headBuffer ? Buffer.concat([this.#headBuffer, data.slice(0, endAt + 1)]) : data.slice(0, endAt + 1)
this.#headBuffer = null
await this.#resolveHead((header + '').trim())
//读取内容
this.#state = ReadState.content
return { left: data.slice(endAt + 1), exit: false }
}
//否则将内容缓存起来,下次处理
else {
this.#headBuffer = this.#headBuffer ? Buffer.concat([this.#headBuffer, data]) : data
return { left: null, exit: true }
}
}
//读取内容(头读取完后,就要读取内容了)
async #readContent(data: Buffer): Promise<IReadResult> {
for (let i = 0; i < data.length; ++i) {
//处理换行
let gotbr = 0
if (data[i] == 10) gotbr = 1
else if (data[i] == 13 && data[i + 1] == 10) gotbr = 2
//遇到换行,那么很有可能就遇到了分隔符
if (gotbr) {
//看看内容够不够,不够啦?先保存起来,剩下的下次处理
if (data.length - i - gotbr - 2 < this.#boundary.length) { //-gotbr表示减去换行符-2表示减去分隔符开始的--
// 保存数据
await this.#resolveData(data.slice(0, i))
//剩下的可能时分隔符的内容留着下次处理
return { left: data.slice(i), exit: true }
}
//内容充足,处理内容
else {
//跳过换行符
i += gotbr
//遇到了 -- ,后面很有可能时分隔符,瞧一眼
if (data[i] == 45 && data[i + 1] == 45) {
//看看是不是遇到了分隔符
if (this.#boundary.compare(data, i + 2, i + 2 + this.#boundary.length) == 0) {
//保存数据
await this.#resolveData(data.slice(0, i - gotbr))
//接下来读取分隔符
this.#state = ReadState.boundary
return { left: data.slice(i), exit: false }
}
}
}
}
}
//没有遇到分隔符,将内容直接保存
await this.#resolveData(data)
return { left: null, exit: true }
}
//收到数据后的处理
async #resolveData(data: Buffer) {
if (!this.#header) return
//普通字段处理
if (this.#header.type == 'field') this.#header.content += data
//文件处理
else await this.#header.file.write(data)
}
//头部读取完成后的处理
async #resolveHead(data: string) {
this.#saveHeader()
this.#header = undefined
const body: { [i in string]: string } = {}
//先按行分割
const liens = data.split(/\r?\n/).map(s => s.trim()).filter(s => !!s)
//处理每行的内容
liens.forEach(line => {
const match = line.match(/^([^:]+):([\s\S]+)$/)
if (!match) return
const key = match[1].trim().toLowerCase()
const val = match[2].trim()
if (key == 'content-disposition') {
const items = val.split(/;/).map(s => s.trim()).filter(s => !!s)
items.forEach(item => {
let [k, v] = item.split(/=/)
if (!v) return
k = k.toLowerCase()
//去除引号
if (v[0] == '"' && v[v.length - 1] == '"') v = v.substring(1, v.length - 1)
else if (v[0] == "'" && v[v.length - 1] == "'") v = v.substring(1, v.length - 1)
//保存
body[k] = v
})
}
else body[key] = val
})
//类型处理
if (body['content-type']) this.#header = {
type: 'file',
name: body.name,
file: new File({ name: body.filename, type: body['content-type'], saveDir: this.#saveDir })
}
else this.#header = {
type: 'field',
name: body.name,
content: '',
}
}
//保存头内容
#saveHeader() {
if (!this.#header) return
if (this.#header.type == 'field') this.#putField(this.#header.name, this.#header.content)
else if (this.#header.type == 'file') this.#putFile(this.#header.name, this.#header.file)
}
//存入字段信息
#putField(name: string, value: string) {
if (!this.#fields[name]) this.#fields[name] = value
else if (this.#fields[name] instanceof Array) (this.#fields as any)[name].push(value)
else (this.#fields[name] = [this.#fields[name] as string]).push(value)
}
//存入文件信息
#putFile(name: string, file: File) {
file.finish()
if (!this.#files[name]) this.#files[name] = file
else if (this.#files[name] instanceof Array) (this.#files as any)[name].push(file)
else (this.#files[name] = [this.#files[name] as File]).push(file)
}
}

100
tsconfig.json Normal file
View File

@ -0,0 +1,100 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
"declarationDir": "./typing", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": [
"src/**/*.ts"
],
"exclude": [
"typing/**/*",
"node_modules"
]
}