Skip to content

Commit 85bd012

Browse files
committed
Initial commit
0 parents  commit 85bd012

File tree

5 files changed

+185
-0
lines changed

5 files changed

+185
-0
lines changed

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: ci
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
strategy:
14+
matrix:
15+
deno-version: [1.9.2]
16+
17+
steps:
18+
- name: Git Checkout Deno Module
19+
uses: actions/checkout@v2
20+
- name: Use Deno Version ${{ matrix.deno-version }}
21+
uses: denolib/setup-deno@v2
22+
with:
23+
deno-version: ${{ matrix.deno-version }}
24+
- name: Lint Deno Module
25+
run: deno fmt --check
26+
- name: Build Deno Module
27+
run: deno run --reload mod.ts
28+
- name: Test Deno Module
29+
run: deno test --allow-none

.vscode/settings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"deno.enable": true,
3+
"editor.tabSize": 2,
4+
"[typescript]": {
5+
"editor.defaultFormatter": "denoland.vscode-deno"
6+
}
7+
}

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# multipart-stream
2+
3+
Create ReadableStreams from multipart forms without allocating the entire form
4+
on the heap.
5+
6+
## Example
7+
8+
```typescript
9+
import { streamFromMultipart } from "https://deno.land/x/multipart_stream/mod.ts";
10+
11+
const [stream, boundary] = streamFromMultipart(async (multipartWriter) => {
12+
const file = await Deno.open("test.bin");
13+
await multipartWriter.writeFile("file", "test.bin", file);
14+
file.close();
15+
});
16+
17+
await fetch("http://example.com/upload", {
18+
headers: {
19+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
20+
},
21+
body: stream,
22+
method: "POST",
23+
});
24+
```

mod.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Channel } from "https://deno.land/x/channo@0.1.1/mod.ts";
2+
import {
3+
MultipartWriter,
4+
} from "https://deno.land/std@0.95.0/mime/multipart.ts";
5+
import {
6+
readableStreamFromIterable,
7+
readableStreamFromReader,
8+
readerFromStreamReader,
9+
} from "https://deno.land/std@0.95.0/io/streams.ts";
10+
11+
interface BytesMessage {
12+
type: "bytes";
13+
buffer: Uint8Array;
14+
}
15+
16+
interface ErrorMessage {
17+
type: "error";
18+
error: unknown;
19+
}
20+
21+
interface DoneMessage {
22+
type: "done";
23+
}
24+
25+
type Message =
26+
| BytesMessage
27+
| ErrorMessage
28+
| DoneMessage;
29+
30+
/**
31+
* Creates a {@link ReadableStream} by serializing a user populated {@link MultipartWriter}.
32+
*
33+
* @param writerFunction A function that receives a prepared {@link MultipartWriter} that the user
34+
* can append fields or files to.
35+
* @returns A tuple of {@link ReadableStream} and the multipart boundary.
36+
*/
37+
export function streamFromMultipart(
38+
writerFunction: (
39+
multipartWriter: MultipartWriter,
40+
) => Promise<void>,
41+
): [ReadableStream<Uint8Array>, string] {
42+
const channel = new Channel<Message>();
43+
44+
// Creates a writer where all of the data is passed to our channel so it can be drained to a
45+
// ReadableStream.
46+
const multipartWriter = new MultipartWriter({
47+
write(buffer: Uint8Array): Promise<number> {
48+
channel.push({ type: "bytes", buffer });
49+
return Promise.resolve(buffer.length);
50+
},
51+
});
52+
53+
// Passes the multipart writer to the caller so they can populate it.
54+
writerFunction(multipartWriter)
55+
.then(() => {
56+
try {
57+
multipartWriter.close();
58+
} catch (_ignored) {
59+
// We'll try to close the writer incase the user hasn't, if they have the close function
60+
// will throw an error we'll just ignore.
61+
}
62+
})
63+
.then(() => channel.push({ type: "done" }))
64+
.catch((error) => channel.push({ type: "error", error }));
65+
66+
// A generator that yields values pushed to our multipart writer.
67+
async function* generator(): AsyncGenerator<Uint8Array, void, undefined> {
68+
for await (const message of channel.stream()) {
69+
if (message.type === "done") {
70+
channel.close();
71+
return;
72+
} else if (message.type === "error") {
73+
throw message.error;
74+
}
75+
76+
yield message.buffer;
77+
}
78+
}
79+
80+
// Yes I know this looks REALLY stupid, but I was having issues where if we used the potentially
81+
// broken stream we would send the data out of order. This fixes it but I have no idea why. It
82+
// doesn't allocate the entire stream on the heap, so I think this is going to stay for now.
83+
const potentiallyBrokenStream = readableStreamFromIterable(generator());
84+
const reader = readerFromStreamReader(potentiallyBrokenStream.getReader());
85+
const stream = readableStreamFromReader(reader);
86+
87+
return [stream, multipartWriter.boundary];
88+
}

test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
isFormFile,
3+
MultipartReader,
4+
} from "https://deno.land/std@0.95.0/mime/multipart.ts";
5+
import {
6+
assert,
7+
assertEquals,
8+
} from "https://deno.land/std@0.95.0/testing/asserts.ts";
9+
import { Buffer } from "https://deno.land/std@0.95.0/io/buffer.ts";
10+
import { readerFromStreamReader } from "https://deno.land/std@0.95.0/io/streams.ts";
11+
import { streamFromMultipart } from "./mod.ts";
12+
13+
const textEncoder = new TextEncoder();
14+
const textBytes = textEncoder.encode("denoland".repeat(1024));
15+
const textBytesReader = new Buffer(textBytes) as Deno.Reader;
16+
17+
Deno.test({
18+
name: "parse",
19+
fn: async () => {
20+
const [stream, boundary] = streamFromMultipart(async (writer) => {
21+
await writer.writeFile("test", "test.bin", textBytesReader);
22+
await writer.writeField("deno", "land");
23+
});
24+
25+
const reader = readerFromStreamReader(stream.getReader());
26+
const multipartReader = new MultipartReader(reader, boundary);
27+
const form = await multipartReader.readForm();
28+
29+
// Ensure the file was serialized correctly.
30+
const formFile = form.file("test");
31+
assert(isFormFile(formFile), "form file is invalid");
32+
assertEquals(formFile.content, textBytes);
33+
34+
// Ensure the field was serialized correctly.
35+
assertEquals(form.value("deno"), "land");
36+
},
37+
});

0 commit comments

Comments
 (0)