1/**
2 * Node.js multipart parsing for HTTP `multipart/form-data` request bodies.
3 *
4 * `NodeMultipart` adapts a Node `Readable` plus incoming HTTP headers into
5 * Effect's shared multipart model. It can expose form parts as a stream or
6 * collect a complete persisted form by writing file uploads to scoped temporary
7 * files through the current `FileSystem` and `Path` services. `fileToReadable`
8 * returns the underlying Node readable stream for file parts produced by this
9 * parser.
10 *
11 * @since 4.0.0
12 */
13import * as Effect from "effect/Effect"
14import type * as FileSystem from "effect/FileSystem"
15import * as Multipart from "effect/http/Multipart"
16import * as MultipartParser from "effect/http/MultipartParser"
17import * as Inspectable from "effect/Inspectable"
18import type * as Path from "effect/Path"
19import type * as Scope from "effect/Scope"
20import * as Stream from "effect/Stream"
21import * as NFS from "node:fs"
22import type { IncomingHttpHeaders } from "node:http"
23import type { Readable } from "node:stream"
24import * as NodeStreamP from "node:stream/promises"
25import * as MP from "./NodeMultipartParser.ts"
26import * as NodeStream from "./NodeStream.ts"
27
28/**
29 * Parses multipart data from a Node readable request body and headers into a
30 * stream of `Multipart.Part` values, converting parser failures to
31 * `MultipartError`.
32 *
33 * @category constructors
34 * @since 4.0.0
35 */
36export const stream = (
37 source: Readable,
38 headers: IncomingHttpHeaders
39): Stream.Stream<Multipart.Part, Multipart.MultipartError> =>
40 Multipart.makeConfig(headers as any).pipe(
41 Effect.map((config) =>
42 NodeStream.fromReadable<MP.Part, Multipart.MultipartError>({
43 evaluate() {
44 const parser = MP.make(config)
45 source.pipe(parser)
46 return parser as any
47 },
48 onError: (error) => convertError(error as any)
49 })
50 ),
51 Stream.unwrap,
52 Stream.map(convertPart)
53 )
54
55/**
56 * Parses multipart data from a Node readable request body and persists file
57 * parts using the current `FileSystem`, `Path`, and `Scope` services.
58 *
59 * @category constructors
60 * @since 4.0.0
61 */
62export const persisted = (
63 source: Readable,
64 headers: IncomingHttpHeaders
65): Effect.Effect<
66 Multipart.Persisted,
67 Multipart.MultipartError,
68 Scope.Scope | FileSystem.FileSystem | Path.Path
69> =>
70 Multipart.toPersisted(stream(source, headers), (path, file) =>
71 Effect.tryPromise({
72 try: (signal) => NodeStreamP.pipeline((file as FileImpl).file, NFS.createWriteStream(path), { signal }),
73 catch: (cause) => Multipart.MultipartError.fromReason("InternalError", cause)
74 }))
75
76/**
77 * Returns the underlying Node readable stream for a multipart file produced by
78 * the Node multipart parser.
79 *
80 * @category converting
81 * @since 4.0.0
82 */
83export const fileToReadable = (file: Multipart.File): Readable => (file as FileImpl).file
84
85// -----------------------------------------------------------------------------
86// Internal
87// -----------------------------------------------------------------------------
88
89const convertPart = (part: MP.Part): Multipart.Part =>
90 part._tag === "Field" ? new FieldImpl(part.info, part.value) : new FileImpl(part)
91
92abstract class PartBase extends Inspectable.Class {
93 readonly [Multipart.TypeId]: typeof Multipart.TypeId
94 constructor() {
95 super()
96 this[Multipart.TypeId] = Multipart.TypeId
97 }
98}
99
100class FieldImpl extends PartBase implements Multipart.Field {
101 readonly _tag = "Field"
102 readonly key: string
103 readonly contentType: string
104 readonly value: string
105
106 constructor(
107 info: MultipartParser.PartInfo,
108 value: Uint8Array
109 ) {
110 super()
111 this.key = info.name
112 this.contentType = info.contentType
113 this.value = MultipartParser.decodeField(info, value)
114 }
115
116 toJSON(): unknown {
117 return {
118 _id: "@effect/platform/Multipart/Part",
119 _tag: "Field",
120 key: this.key,
121 value: this.value,
122 contentType: this.contentType
123 }
124 }
125}
126
127class FileImpl extends PartBase implements Multipart.File {
128 readonly _tag = "File"
129 readonly key: string
130 readonly name: string
131 readonly contentType: string
132 readonly content: Stream.Stream<Uint8Array, Multipart.MultipartError>
133 readonly contentEffect: Effect.Effect<Uint8Array, Multipart.MultipartError>
134 readonly file: MP.FileStream
135
136 constructor(file: MP.FileStream) {
137 super()
138 this.file = file
139 this.key = file.info.name
140 this.name = file.filename ?? file.info.name
141 this.contentType = file.info.contentType
142 this.content = NodeStream.fromReadable({
143 evaluate: () => file,
144 onError: (cause) => Multipart.MultipartError.fromReason("InternalError", cause)
145 })
146 this.contentEffect = NodeStream.toUint8Array(() => file, {
147 onError: (cause) => Multipart.MultipartError.fromReason("InternalError", cause)
148 })
149 }
150
151 toJSON(): unknown {
152 return {
153 _id: "@effect/platform/Multipart/Part",
154 _tag: "File",
155 key: this.key,
156 name: this.name,
157 contentType: this.contentType
158 }
159 }
160}
161
162function convertError(cause: MultipartParser.MultipartError): Multipart.MultipartError {
163 switch (cause._tag) {
164 case "ReachedLimit": {
165 switch (cause.limit) {
166 case "MaxParts": {
167 return Multipart.MultipartError.fromReason("TooManyParts", cause)
168 }
169 case "MaxFieldSize": {
170 return Multipart.MultipartError.fromReason("FieldTooLarge", cause)
171 }
172 case "MaxPartSize": {
173 return Multipart.MultipartError.fromReason("FileTooLarge", cause)
174 }
175 case "MaxTotalSize": {
176 return Multipart.MultipartError.fromReason("BodyTooLarge", cause)
177 }
178 }
179 }
180 default: {
181 return Multipart.MultipartError.fromReason("Parse", cause)
182 }
183 }
184}