Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"eslint-config-unjs"
],
"rules": {
"unicorn/no-null": 0
"unicorn/no-null": "off",
"unicorn/number-literal-case": "off"
}
}
46 changes: 30 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,27 +39,34 @@ pnpm add h3
## Usage

```ts
import { createServer } from 'http'
import { createApp, eventHandler, toNodeListener } from 'h3'
import { createServer } from "http";
import { createApp, eventHandler, toNodeListener } from "h3";

const app = createApp()
app.use('/', eventHandler(() => 'Hello world!'))
const app = createApp();
app.use(
"/",
eventHandler(() => "Hello world!")
);

createServer(toNodeListener(app)).listen(process.env.PORT || 3000)
createServer(toNodeListener(app)).listen(process.env.PORT || 3000);
```

<details>
<summary>Example using <a href="https://github.com/unjs/listhen">listhen</a> for an elegant listener.</summary>

```ts
import { createApp, toNodeListener } from 'h3'
import { listen } from 'listhen'
import { createApp, toNodeListener } from "h3";
import { listen } from "listhen";

const app = createApp()
app.use('/', eventHandler(() => 'Hello world!'))
const app = createApp();
app.use(
"/",
eventHandler(() => "Hello world!")
);

listen(toNodeListener(app))
listen(toNodeListener(app));
```

</details>

## Router
Expand All @@ -69,15 +76,21 @@ The `app` instance created by `h3` uses a middleware stack (see [how it works](#
To opt-in using a more advanced and convenient routing system, we can create a router instance and register it to app instance.

```ts
import { createApp, eventHandler, createRouter } from 'h3'
import { createApp, eventHandler, createRouter } from "h3";

const app = createApp()
const app = createApp();

const router = createRouter()
.get('/', eventHandler(() => 'Hello World!'))
.get('/hello/:name', eventHandler(event => `Hello ${event.context.params.name}!`))

app.use(router)
.get(
"/",
eventHandler(() => "Hello World!")
)
.get(
"/hello/:name",
eventHandler((event) => `Hello ${event.context.params.name}!`)
);

app.use(router);
```

**Tip:** We can register same route more than once with different methods.
Expand Down Expand Up @@ -138,6 +151,7 @@ H3 has concept of compasable utilities that accept `event` (from `eventHandler((
- `createError({ statusCode, statusMessage, data? })`
- `sendProxy(event, { target, headers?, fetchOptions?, fetch?, sendStream? })`
- `proxyRequest(event, { target, headers?, fetchOptions?, fetch?, sendStream? })`
- `readMultipartFormData(event)`

πŸ‘‰ You can learn more about usage in [JSDocs Documentation](https://www.jsdocs.io/package/h3#package-functions).

Expand Down
19 changes: 18 additions & 1 deletion src/utils/body.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import destr from "destr";
import type { Encoding, HTTPMethod } from "../types";
import type { H3Event } from "../event";
import { assertMethod } from "./request";
import { parse as parseMultipartData } from "./multipart";
import { assertMethod, getRequestHeader } from "./request";

const RawBodySymbol = Symbol.for("h3RawBody");
const ParsedBodySymbol = Symbol.for("h3ParsedBody");
Expand Down Expand Up @@ -102,3 +103,19 @@ export async function readBody<T = any>(event: H3Event): Promise<T> {
(event.node.req as any)[ParsedBodySymbol] = json;
return json;
}

export async function readMultipartFormData(event: H3Event) {
const contentType = getRequestHeader(event, "content-type");
if (!contentType || !contentType.startsWith("multipart/form-data")) {
return;
}
const boundary = contentType.match(/boundary=([^;]*)(;|$)/i)?.[1];
if (!boundary) {
return;
}
const body = await readRawBody(event, false);
if (!body) {
return;
}
return parseMultipartData(body, boundary);
}
123 changes: 123 additions & 0 deletions src/utils/multipart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
Based on:
https://github.com/freesoftwarefactory/parse-multipart - MIT License (The Free Software Factory)
https://github.com/nachomazzara/parse-multipart-data - a44c95319d09fd7d7ba51e01512567c444b90e14 - Ignacio Mazzara
*/

export interface MultiPartData {
data: Buffer;
name?: string;
filename?: string;
type?: string;
}

enum ParsingState {
INIT,
READING_HEADERS,
READING_DATA,
READING_PART_SEPARATOR,
}

export function parse(
multipartBodyBuffer: Buffer,
boundary: string
): MultiPartData[] {
let lastline = "";
let state: ParsingState = ParsingState.INIT;
let buffer: number[] = [];
const allParts: MultiPartData[] = [];

let currentPartHeaders: [string, string][] = [];

for (let i = 0; i < multipartBodyBuffer.length; i++) {
const prevByte: number | null = i > 0 ? multipartBodyBuffer[i - 1] : null;
const currByte: number = multipartBodyBuffer[i];

// 0x0a => \n | 0x0d => \r
const newLineChar: boolean = currByte === 0x0a || currByte === 0x0d;
if (!newLineChar) {
lastline += String.fromCodePoint(currByte);
}

const newLineDetected: boolean = currByte === 0x0a && prevByte === 0x0d;
if (ParsingState.INIT === state && newLineDetected) {
// Searching for boundary
if ("--" + boundary === lastline) {
state = ParsingState.READING_HEADERS; // Found boundary. start reading headers
}
lastline = "";
} else if (ParsingState.READING_HEADERS === state && newLineDetected) {
// Parsing headers.
// Headers are separated by an empty line from the content.
// Stop reading headers when the line is empty
if (lastline.length > 0) {
const i = lastline.indexOf(":");
if (i > 0) {
const name = lastline.slice(0, i).toLowerCase();
const value = lastline.slice(i + 1).trim();
currentPartHeaders.push([name, value]);
}
} else {
// Found empty line.
// Reading headers is finished.
state = ParsingState.READING_DATA;
buffer = [];
}
lastline = "";
} else if (ParsingState.READING_DATA === state) {
// Parsing data
if (lastline.length > boundary.length + 4) {
lastline = ""; // Free memory
}
if ("--" + boundary === lastline) {
const j = buffer.length - lastline.length;
const part = buffer.slice(0, j - 1);
allParts.push(process(part, currentPartHeaders));
buffer = [];
currentPartHeaders = [];
lastline = "";
state = ParsingState.READING_PART_SEPARATOR;
} else {
buffer.push(currByte);
}
if (newLineDetected) {
lastline = "";
}
} else if (
ParsingState.READING_PART_SEPARATOR === state &&
newLineDetected
) {
state = ParsingState.READING_HEADERS;
}
}
return allParts;
}

function process(data: number[], headers: [string, string][]): MultiPartData {
const dataObj: Partial<MultiPartData> = {};

// Meta
const contentDispositionHeader =
headers.find((h) => h[0] === "content-disposition")?.[1] || "";
for (const i of contentDispositionHeader.split(";")) {
const s = i.split("=");
if (s.length !== 2) {
continue;
}
const key = (s[0] || "").trim();
if (key === "name" || key === "filename") {
dataObj[key] = (s[1] || "").trim().replace(/"/g, "");
}
}

// Type
const contentType = headers.find((h) => h[0] === "content-type")?.[1] || "";
if (contentType) {
dataObj.type = contentType;
}

// Data
dataObj.data = Buffer.from(data);

return dataObj as MultiPartData;
}
36 changes: 36 additions & 0 deletions test/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
readRawBody,
readBody,
eventHandler,
readMultipartFormData,
} from "../src";

describe("", () => {
Expand Down Expand Up @@ -172,5 +173,40 @@ describe("", () => {

expect(result.text).toBe("200");
});

it("parses multipart form data", async () => {
app.use(
"/",
eventHandler(async (request) => {
const parts = (await readMultipartFormData(request)) || [];
return parts.map((part) => ({
...part,
data: part.data.toString("utf8"),
}));
})
);
const result = await request
.post("/api/test")
.set(
"content-type",
"multipart/form-data; boundary=---------------------------12537827810750053901680552518"
)
.send(
'-----------------------------12537827810750053901680552518\r\nContent-Disposition: form-data; name="baz"\r\n\r\nother\r\n-----------------------------12537827810750053901680552518\r\nContent-Disposition: form-data; name="bar"\r\n\r\nsomething\r\n-----------------------------12537827810750053901680552518--\r\n'
);

expect(result.body).toMatchInlineSnapshot(`
[
{
"data": "other",
"name": "baz",
},
{
"data": "something",
"name": "bar",
},
]
`);
});
});
});