@effect/platform 0.85.1 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/HttpApiBuilder.js +15 -5
- package/dist/cjs/HttpApiBuilder.js.map +1 -1
- package/dist/cjs/HttpApiSchema.js +8 -10
- package/dist/cjs/HttpApiSchema.js.map +1 -1
- package/dist/cjs/HttpIncomingMessage.js +7 -5
- package/dist/cjs/HttpIncomingMessage.js.map +1 -1
- package/dist/cjs/HttpServerRequest.js +10 -4
- package/dist/cjs/HttpServerRequest.js.map +1 -1
- package/dist/cjs/Multipart.js +400 -47
- package/dist/cjs/Multipart.js.map +1 -1
- package/dist/dts/HttpApiBuilder.d.ts.map +1 -1
- package/dist/dts/HttpApiSchema.d.ts +19 -4
- package/dist/dts/HttpApiSchema.d.ts.map +1 -1
- package/dist/dts/HttpIncomingMessage.d.ts +5 -2
- package/dist/dts/HttpIncomingMessage.d.ts.map +1 -1
- package/dist/dts/HttpServerRequest.d.ts +6 -1
- package/dist/dts/HttpServerRequest.d.ts.map +1 -1
- package/dist/dts/Multipart.d.ts +167 -88
- package/dist/dts/Multipart.d.ts.map +1 -1
- package/dist/esm/HttpApiBuilder.js +15 -5
- package/dist/esm/HttpApiBuilder.js.map +1 -1
- package/dist/esm/HttpApiSchema.js +8 -10
- package/dist/esm/HttpApiSchema.js.map +1 -1
- package/dist/esm/HttpIncomingMessage.js +5 -4
- package/dist/esm/HttpIncomingMessage.js.map +1 -1
- package/dist/esm/HttpServerRequest.js +6 -1
- package/dist/esm/HttpServerRequest.js.map +1 -1
- package/dist/esm/Multipart.js +385 -46
- package/dist/esm/Multipart.js.map +1 -1
- package/package.json +2 -2
- package/src/HttpApiBuilder.ts +16 -5
- package/src/HttpApiSchema.ts +25 -11
- package/src/HttpIncomingMessage.ts +5 -7
- package/src/HttpServerRequest.ts +6 -1
- package/src/Multipart.ts +632 -128
- package/dist/cjs/internal/multipart.js +0 -364
- package/dist/cjs/internal/multipart.js.map +0 -1
- package/dist/dts/internal/multipart.d.ts +0 -2
- package/dist/dts/internal/multipart.d.ts.map +0 -1
- package/dist/esm/internal/multipart.js +0 -347
- package/dist/esm/internal/multipart.js.map +0 -1
- package/src/internal/multipart.ts +0 -491
package/src/Multipart.ts
CHANGED
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @since 1.0.0
|
|
3
3
|
*/
|
|
4
|
-
import type
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import
|
|
4
|
+
import type * as Cause from "effect/Cause"
|
|
5
|
+
import * as Channel from "effect/Channel"
|
|
6
|
+
import * as Chunk from "effect/Chunk"
|
|
7
|
+
import * as Context from "effect/Context"
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import * as Exit from "effect/Exit"
|
|
10
|
+
import { constant, dual } from "effect/Function"
|
|
11
|
+
import * as Inspectable from "effect/Inspectable"
|
|
12
|
+
import * as Mailbox from "effect/Mailbox"
|
|
13
|
+
import * as Option from "effect/Option"
|
|
11
14
|
import type * as ParseResult from "effect/ParseResult"
|
|
12
|
-
import
|
|
15
|
+
import * as Predicate from "effect/Predicate"
|
|
16
|
+
import * as Schema from "effect/Schema"
|
|
13
17
|
import type { ParseOptions } from "effect/SchemaAST"
|
|
14
18
|
import type * as Scope from "effect/Scope"
|
|
15
|
-
import type * as
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import * as
|
|
19
|
-
import
|
|
19
|
+
import type * as AsyncInput from "effect/SingleProducerAsyncInput"
|
|
20
|
+
import * as Stream from "effect/Stream"
|
|
21
|
+
import * as MP from "multipasta"
|
|
22
|
+
import * as FileSystem from "./FileSystem.js"
|
|
23
|
+
import * as IncomingMessage from "./HttpIncomingMessage.js"
|
|
24
|
+
import * as Path from "./Path.js"
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* @since 1.0.0
|
|
23
28
|
* @category type ids
|
|
24
29
|
*/
|
|
25
|
-
export const TypeId: unique symbol =
|
|
30
|
+
export const TypeId: unique symbol = Symbol.for("@effect/platform/Multipart")
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
33
|
* @since 1.0.0
|
|
@@ -36,12 +41,6 @@ export type TypeId = typeof TypeId
|
|
|
36
41
|
*/
|
|
37
42
|
export type Part = Field | File
|
|
38
43
|
|
|
39
|
-
/**
|
|
40
|
-
* @since 1.0.0
|
|
41
|
-
* @category refinements
|
|
42
|
-
*/
|
|
43
|
-
export const isPart: (u: unknown) => u is Part = internal.isPart
|
|
44
|
-
|
|
45
44
|
/**
|
|
46
45
|
* @since 1.0.0
|
|
47
46
|
*/
|
|
@@ -50,7 +49,7 @@ export declare namespace Part {
|
|
|
50
49
|
* @since 1.0.0
|
|
51
50
|
* @category models
|
|
52
51
|
*/
|
|
53
|
-
export interface Proto extends Inspectable {
|
|
52
|
+
export interface Proto extends Inspectable.Inspectable {
|
|
54
53
|
readonly [TypeId]: TypeId
|
|
55
54
|
readonly _tag: string
|
|
56
55
|
}
|
|
@@ -69,9 +68,15 @@ export interface Field extends Part.Proto {
|
|
|
69
68
|
|
|
70
69
|
/**
|
|
71
70
|
* @since 1.0.0
|
|
72
|
-
* @category
|
|
71
|
+
* @category Guards
|
|
73
72
|
*/
|
|
74
|
-
export const
|
|
73
|
+
export const isPart = (u: unknown): u is Part => Predicate.hasProperty(u, TypeId)
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @since 1.0.0
|
|
77
|
+
* @category Guards
|
|
78
|
+
*/
|
|
79
|
+
export const isField = (u: unknown): u is Field => isPart(u) && u._tag === "Field"
|
|
75
80
|
|
|
76
81
|
/**
|
|
77
82
|
* @since 1.0.0
|
|
@@ -88,9 +93,9 @@ export interface File extends Part.Proto {
|
|
|
88
93
|
|
|
89
94
|
/**
|
|
90
95
|
* @since 1.0.0
|
|
91
|
-
* @category
|
|
96
|
+
* @category Guards
|
|
92
97
|
*/
|
|
93
|
-
export const isFile
|
|
98
|
+
export const isFile = (u: unknown): u is File => isPart(u) && u._tag === "File"
|
|
94
99
|
|
|
95
100
|
/**
|
|
96
101
|
* @since 1.0.0
|
|
@@ -106,9 +111,10 @@ export interface PersistedFile extends Part.Proto {
|
|
|
106
111
|
|
|
107
112
|
/**
|
|
108
113
|
* @since 1.0.0
|
|
109
|
-
* @category
|
|
114
|
+
* @category Guards
|
|
110
115
|
*/
|
|
111
|
-
export const isPersistedFile
|
|
116
|
+
export const isPersistedFile = (u: unknown): u is PersistedFile =>
|
|
117
|
+
Predicate.hasProperty(u, TypeId) && Predicate.isTagged(u, "PersistedFile")
|
|
112
118
|
|
|
113
119
|
/**
|
|
114
120
|
* @since 1.0.0
|
|
@@ -120,44 +126,594 @@ export interface Persisted {
|
|
|
120
126
|
|
|
121
127
|
/**
|
|
122
128
|
* @since 1.0.0
|
|
123
|
-
* @category
|
|
129
|
+
* @category Errors
|
|
124
130
|
*/
|
|
125
|
-
export const ErrorTypeId: unique symbol =
|
|
131
|
+
export const ErrorTypeId: unique symbol = Symbol.for(
|
|
132
|
+
"@effect/platform/Multipart/MultipartError"
|
|
133
|
+
)
|
|
126
134
|
|
|
127
135
|
/**
|
|
128
136
|
* @since 1.0.0
|
|
129
|
-
* @category
|
|
137
|
+
* @category Errors
|
|
130
138
|
*/
|
|
131
139
|
export type ErrorTypeId = typeof ErrorTypeId
|
|
132
140
|
|
|
133
141
|
/**
|
|
134
142
|
* @since 1.0.0
|
|
135
|
-
* @category
|
|
143
|
+
* @category Errors
|
|
136
144
|
*/
|
|
137
|
-
export
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
export class MultipartError extends Schema.TaggedError<MultipartError>()("MultipartError", {
|
|
146
|
+
reason: Schema.Literal("FileTooLarge", "FieldTooLarge", "BodyTooLarge", "TooManyParts", "InternalError", "Parse"),
|
|
147
|
+
cause: Schema.Defect
|
|
148
|
+
}) {
|
|
149
|
+
/**
|
|
150
|
+
* @since 1.0.0
|
|
151
|
+
*/
|
|
152
|
+
readonly [ErrorTypeId]: ErrorTypeId = ErrorTypeId
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @since 1.0.0
|
|
156
|
+
*/
|
|
157
|
+
get message(): string {
|
|
158
|
+
return this.reason
|
|
159
|
+
}
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
/**
|
|
146
163
|
* @since 1.0.0
|
|
147
|
-
* @category
|
|
164
|
+
* @category Schemas
|
|
148
165
|
*/
|
|
149
|
-
export const
|
|
166
|
+
export const FileSchema: Schema.Schema<PersistedFile> = Schema.declare(isPersistedFile, {
|
|
167
|
+
identifier: "PersistedFile",
|
|
168
|
+
jsonSchema: {
|
|
169
|
+
type: "string",
|
|
170
|
+
format: "binary"
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @since 1.0.0
|
|
176
|
+
* @category Schemas
|
|
177
|
+
*/
|
|
178
|
+
export const FilesSchema: Schema.Schema<ReadonlyArray<PersistedFile>> = Schema.Array(FileSchema)
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @since 1.0.0
|
|
182
|
+
* @category Schemas
|
|
183
|
+
*/
|
|
184
|
+
export const SingleFileSchema: Schema.transform<
|
|
185
|
+
Schema.Schema<ReadonlyArray<PersistedFile>>,
|
|
186
|
+
Schema.Schema<PersistedFile>
|
|
187
|
+
> = Schema.transform(FilesSchema.pipe(Schema.itemsCount(1)), FileSchema, {
|
|
188
|
+
strict: true,
|
|
189
|
+
decode: ([file]) => file,
|
|
190
|
+
encode: (file) => [file]
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @since 1.0.0
|
|
195
|
+
* @category Schemas
|
|
196
|
+
*/
|
|
197
|
+
export const schemaPersisted = <A, I extends Partial<Persisted>, R>(
|
|
198
|
+
schema: Schema.Schema<A, I, R>,
|
|
199
|
+
options?: ParseOptions | undefined
|
|
200
|
+
): (persisted: Persisted) => Effect.Effect<
|
|
201
|
+
A,
|
|
202
|
+
ParseResult.ParseError,
|
|
203
|
+
R
|
|
204
|
+
> => Schema.decodeUnknown(schema, options)
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @since 1.0.0
|
|
208
|
+
* @category Schemas
|
|
209
|
+
*/
|
|
210
|
+
export const schemaJson = <A, I, R>(schema: Schema.Schema<A, I, R>, options?: ParseOptions | undefined): {
|
|
211
|
+
(
|
|
212
|
+
field: string
|
|
213
|
+
): (persisted: Persisted) => Effect.Effect<A, ParseResult.ParseError, R>
|
|
214
|
+
(
|
|
215
|
+
persisted: Persisted,
|
|
216
|
+
field: string
|
|
217
|
+
): Effect.Effect<A, ParseResult.ParseError, R>
|
|
218
|
+
} => {
|
|
219
|
+
const fromJson = Schema.parseJson(schema)
|
|
220
|
+
return dual<
|
|
221
|
+
(
|
|
222
|
+
field: string
|
|
223
|
+
) => (
|
|
224
|
+
persisted: Persisted
|
|
225
|
+
) => Effect.Effect<A, ParseResult.ParseError, R>,
|
|
226
|
+
(
|
|
227
|
+
persisted: Persisted,
|
|
228
|
+
field: string
|
|
229
|
+
) => Effect.Effect<A, ParseResult.ParseError, R>
|
|
230
|
+
>(2, (persisted, field) =>
|
|
231
|
+
Effect.map(
|
|
232
|
+
Schema.decodeUnknown(
|
|
233
|
+
Schema.Struct({
|
|
234
|
+
[field]: fromJson
|
|
235
|
+
}),
|
|
236
|
+
options
|
|
237
|
+
)(persisted),
|
|
238
|
+
(_) => _[field]
|
|
239
|
+
))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @since 1.0.0
|
|
244
|
+
* @category Config
|
|
245
|
+
*/
|
|
246
|
+
export const makeConfig = (
|
|
247
|
+
headers: Record<string, string>
|
|
248
|
+
): Effect.Effect<MP.BaseConfig> =>
|
|
249
|
+
Effect.withFiberRuntime((fiber) => {
|
|
250
|
+
const mimeTypes = Context.get(fiber.currentContext, FieldMimeTypes)
|
|
251
|
+
return Effect.succeed<MP.BaseConfig>({
|
|
252
|
+
headers,
|
|
253
|
+
maxParts: Option.getOrUndefined(Context.get(fiber.currentContext, MaxParts)),
|
|
254
|
+
maxFieldSize: Number(Context.get(fiber.currentContext, MaxFieldSize)),
|
|
255
|
+
maxPartSize: Context.get(fiber.currentContext, MaxFileSize).pipe(Option.map(Number), Option.getOrUndefined),
|
|
256
|
+
maxTotalSize: Context.get(fiber.currentContext, IncomingMessage.MaxBodySize).pipe(
|
|
257
|
+
Option.map(Number),
|
|
258
|
+
Option.getOrUndefined
|
|
259
|
+
),
|
|
260
|
+
isFile: mimeTypes.length === 0 ? undefined : (info: MP.PartInfo): boolean =>
|
|
261
|
+
!Chunk.some(
|
|
262
|
+
mimeTypes,
|
|
263
|
+
(_) => info.contentType.includes(_)
|
|
264
|
+
) && MP.defaultIsFile(info)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @since 1.0.0
|
|
270
|
+
* @category Parsers
|
|
271
|
+
*/
|
|
272
|
+
export const makeChannel = <IE>(
|
|
273
|
+
headers: Record<string, string>,
|
|
274
|
+
bufferSize = 16
|
|
275
|
+
): Channel.Channel<
|
|
276
|
+
Chunk.Chunk<Part>,
|
|
277
|
+
Chunk.Chunk<Uint8Array>,
|
|
278
|
+
MultipartError | IE,
|
|
279
|
+
IE,
|
|
280
|
+
unknown,
|
|
281
|
+
unknown
|
|
282
|
+
> =>
|
|
283
|
+
Channel.acquireUseRelease(
|
|
284
|
+
Effect.all([
|
|
285
|
+
makeConfig(headers),
|
|
286
|
+
Mailbox.make<Chunk.Chunk<Uint8Array>>(bufferSize)
|
|
287
|
+
]),
|
|
288
|
+
([config, mailbox]) => {
|
|
289
|
+
let partsBuffer: Array<Part> = []
|
|
290
|
+
let exit = Option.none<Exit.Exit<void, IE | MultipartError>>()
|
|
291
|
+
|
|
292
|
+
const input: AsyncInput.AsyncInputProducer<IE, Chunk.Chunk<Uint8Array>, unknown> = {
|
|
293
|
+
awaitRead: () => Effect.void,
|
|
294
|
+
emit(element) {
|
|
295
|
+
return mailbox.offer(element)
|
|
296
|
+
},
|
|
297
|
+
error(cause) {
|
|
298
|
+
exit = Option.some(Exit.failCause(cause))
|
|
299
|
+
return mailbox.end
|
|
300
|
+
},
|
|
301
|
+
done(_value) {
|
|
302
|
+
return mailbox.end
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const parser = MP.make({
|
|
307
|
+
...config,
|
|
308
|
+
onField(info, value) {
|
|
309
|
+
partsBuffer.push(new FieldImpl(info.name, info.contentType, MP.decodeField(info, value)))
|
|
310
|
+
},
|
|
311
|
+
onFile(info) {
|
|
312
|
+
let chunks: Array<Uint8Array> = []
|
|
313
|
+
let finished = false
|
|
314
|
+
const take: Channel.Channel<Chunk.Chunk<Uint8Array>> = Channel.suspend(() => {
|
|
315
|
+
if (chunks.length === 0) {
|
|
316
|
+
return finished ? Channel.void : Channel.zipRight(pump, take)
|
|
317
|
+
}
|
|
318
|
+
const chunk = Chunk.unsafeFromArray(chunks)
|
|
319
|
+
chunks = []
|
|
320
|
+
return finished ? Channel.write(chunk) : Channel.zipRight(
|
|
321
|
+
Channel.write(chunk),
|
|
322
|
+
Channel.zipRight(pump, take)
|
|
323
|
+
)
|
|
324
|
+
})
|
|
325
|
+
partsBuffer.push(new FileImpl(info, take))
|
|
326
|
+
return function(chunk) {
|
|
327
|
+
if (chunk === null) {
|
|
328
|
+
finished = true
|
|
329
|
+
} else {
|
|
330
|
+
chunks.push(chunk)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
onError(error_) {
|
|
335
|
+
exit = Option.some(Exit.fail(convertError(error_)))
|
|
336
|
+
},
|
|
337
|
+
onDone() {
|
|
338
|
+
exit = Option.some(Exit.void)
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const pump = Channel.flatMap(
|
|
343
|
+
mailbox.takeAll,
|
|
344
|
+
([chunks, done]) =>
|
|
345
|
+
Channel.sync(() => {
|
|
346
|
+
Chunk.forEach(chunks, Chunk.forEach(parser.write))
|
|
347
|
+
if (done) {
|
|
348
|
+
parser.end()
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
const partsChannel: Channel.Channel<
|
|
354
|
+
Chunk.Chunk<Part>,
|
|
355
|
+
unknown,
|
|
356
|
+
IE | MultipartError
|
|
357
|
+
> = Channel.flatMap(
|
|
358
|
+
pump,
|
|
359
|
+
() => {
|
|
360
|
+
if (partsBuffer.length === 0) {
|
|
361
|
+
return exit._tag === "None" ? partsChannel : writeExit(exit.value)
|
|
362
|
+
}
|
|
363
|
+
const chunk = Chunk.unsafeFromArray(partsBuffer)
|
|
364
|
+
partsBuffer = []
|
|
365
|
+
return Channel.zipRight(
|
|
366
|
+
Channel.write(chunk),
|
|
367
|
+
exit._tag === "None" ? partsChannel : writeExit(exit.value)
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
return Channel.embedInput(partsChannel, input)
|
|
373
|
+
},
|
|
374
|
+
([, mailbox]) => mailbox.shutdown
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
const writeExit = <A, E>(
|
|
378
|
+
self: Exit.Exit<A, E>
|
|
379
|
+
): Channel.Channel<never, unknown, E> => self._tag === "Success" ? Channel.void : Channel.failCause(self.cause)
|
|
380
|
+
|
|
381
|
+
function convertError(cause: MP.MultipartError): MultipartError {
|
|
382
|
+
switch (cause._tag) {
|
|
383
|
+
case "ReachedLimit": {
|
|
384
|
+
switch (cause.limit) {
|
|
385
|
+
case "MaxParts": {
|
|
386
|
+
return new MultipartError({ reason: "TooManyParts", cause })
|
|
387
|
+
}
|
|
388
|
+
case "MaxFieldSize": {
|
|
389
|
+
return new MultipartError({ reason: "FieldTooLarge", cause })
|
|
390
|
+
}
|
|
391
|
+
case "MaxPartSize": {
|
|
392
|
+
return new MultipartError({ reason: "FileTooLarge", cause })
|
|
393
|
+
}
|
|
394
|
+
case "MaxTotalSize": {
|
|
395
|
+
return new MultipartError({ reason: "BodyTooLarge", cause })
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
default: {
|
|
400
|
+
return new MultipartError({ reason: "Parse", cause })
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
abstract class PartBase extends Inspectable.Class {
|
|
406
|
+
readonly [TypeId]: TypeId
|
|
407
|
+
constructor() {
|
|
408
|
+
super()
|
|
409
|
+
this[TypeId] = TypeId
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
class FieldImpl extends PartBase implements Field {
|
|
414
|
+
readonly _tag = "Field"
|
|
415
|
+
|
|
416
|
+
constructor(
|
|
417
|
+
readonly key: string,
|
|
418
|
+
readonly contentType: string,
|
|
419
|
+
readonly value: string
|
|
420
|
+
) {
|
|
421
|
+
super()
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
toJSON(): unknown {
|
|
425
|
+
return {
|
|
426
|
+
_id: "@effect/platform/Multipart/Part",
|
|
427
|
+
_tag: "Field",
|
|
428
|
+
key: this.key,
|
|
429
|
+
contentType: this.contentType,
|
|
430
|
+
value: this.value
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
class FileImpl extends PartBase implements File {
|
|
436
|
+
readonly _tag = "File"
|
|
437
|
+
readonly key: string
|
|
438
|
+
readonly name: string
|
|
439
|
+
readonly contentType: string
|
|
440
|
+
readonly content: Stream.Stream<Uint8Array, MultipartError>
|
|
441
|
+
readonly contentEffect: Effect.Effect<Uint8Array, MultipartError>
|
|
442
|
+
|
|
443
|
+
constructor(
|
|
444
|
+
info: MP.PartInfo,
|
|
445
|
+
channel: Channel.Channel<Chunk.Chunk<Uint8Array>, unknown, never, unknown, void, unknown>
|
|
446
|
+
) {
|
|
447
|
+
super()
|
|
448
|
+
this.key = info.name
|
|
449
|
+
this.name = info.filename ?? info.name
|
|
450
|
+
this.contentType = info.contentType
|
|
451
|
+
this.content = Stream.fromChannel(channel)
|
|
452
|
+
this.contentEffect = channel.pipe(
|
|
453
|
+
Channel.pipeTo(collectUint8Array),
|
|
454
|
+
Channel.run,
|
|
455
|
+
Effect.mapError((cause) => new MultipartError({ reason: "InternalError", cause }))
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
toJSON(): unknown {
|
|
460
|
+
return {
|
|
461
|
+
_id: "@effect/platform/Multipart/Part",
|
|
462
|
+
_tag: "File",
|
|
463
|
+
key: this.key,
|
|
464
|
+
name: this.name,
|
|
465
|
+
contentType: this.contentType
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const defaultWriteFile = (path: string, file: File) =>
|
|
471
|
+
Effect.flatMap(
|
|
472
|
+
FileSystem.FileSystem,
|
|
473
|
+
(fs) =>
|
|
474
|
+
Effect.mapError(
|
|
475
|
+
Stream.run(file.content, fs.sink(path)),
|
|
476
|
+
(cause) => new MultipartError({ reason: "InternalError", cause })
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* @since 1.0.0
|
|
482
|
+
*/
|
|
483
|
+
export const collectUint8Array = Channel.suspend(() => {
|
|
484
|
+
let accumulator = new Uint8Array(0)
|
|
485
|
+
const loop: Channel.Channel<
|
|
486
|
+
never,
|
|
487
|
+
Chunk.Chunk<Uint8Array>,
|
|
488
|
+
unknown,
|
|
489
|
+
unknown,
|
|
490
|
+
Uint8Array
|
|
491
|
+
> = Channel.readWithCause({
|
|
492
|
+
onInput(chunk: Chunk.Chunk<Uint8Array>) {
|
|
493
|
+
for (const element of chunk) {
|
|
494
|
+
const newAccumulator = new Uint8Array(accumulator.length + element.length)
|
|
495
|
+
newAccumulator.set(accumulator, 0)
|
|
496
|
+
newAccumulator.set(element, accumulator.length)
|
|
497
|
+
accumulator = newAccumulator
|
|
498
|
+
}
|
|
499
|
+
return loop
|
|
500
|
+
},
|
|
501
|
+
onFailure: (cause: Cause.Cause<unknown>) => Channel.failCause(cause),
|
|
502
|
+
onDone: () => Channel.succeed(accumulator)
|
|
503
|
+
})
|
|
504
|
+
return loop
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* @since 1.0.0
|
|
509
|
+
* @category Conversions
|
|
510
|
+
*/
|
|
511
|
+
export const toPersisted = (
|
|
512
|
+
stream: Stream.Stream<Part, MultipartError>,
|
|
513
|
+
writeFile = defaultWriteFile
|
|
514
|
+
): Effect.Effect<Persisted, MultipartError, FileSystem.FileSystem | Path.Path | Scope.Scope> =>
|
|
515
|
+
Effect.gen(function*() {
|
|
516
|
+
const fs = yield* FileSystem.FileSystem
|
|
517
|
+
const path_ = yield* Path.Path
|
|
518
|
+
const dir = yield* fs.makeTempDirectoryScoped()
|
|
519
|
+
const persisted: Record<string, Array<PersistedFile> | Array<string> | string> = Object.create(null)
|
|
520
|
+
yield* Stream.runForEach(stream, (part) => {
|
|
521
|
+
if (part._tag === "Field") {
|
|
522
|
+
if (!(part.key in persisted)) {
|
|
523
|
+
persisted[part.key] = part.value
|
|
524
|
+
} else if (typeof persisted[part.key] === "string") {
|
|
525
|
+
persisted[part.key] = [persisted[part.key] as string, part.value]
|
|
526
|
+
} else {
|
|
527
|
+
;(persisted[part.key] as Array<string>).push(part.value)
|
|
528
|
+
}
|
|
529
|
+
return Effect.void
|
|
530
|
+
} else if (part.name === "") {
|
|
531
|
+
return Effect.void
|
|
532
|
+
}
|
|
533
|
+
const file = part
|
|
534
|
+
const path = path_.join(dir, path_.basename(file.name).slice(-128))
|
|
535
|
+
const filePart = new PersistedFileImpl(
|
|
536
|
+
file.key,
|
|
537
|
+
file.name,
|
|
538
|
+
file.contentType,
|
|
539
|
+
path
|
|
540
|
+
)
|
|
541
|
+
if (Array.isArray(persisted[part.key])) {
|
|
542
|
+
;(persisted[part.key] as Array<PersistedFile>).push(filePart)
|
|
543
|
+
} else {
|
|
544
|
+
persisted[part.key] = [filePart]
|
|
545
|
+
}
|
|
546
|
+
return writeFile(path, file)
|
|
547
|
+
})
|
|
548
|
+
return persisted
|
|
549
|
+
}).pipe(
|
|
550
|
+
Effect.catchTags({
|
|
551
|
+
SystemError: (cause) => Effect.fail(new MultipartError({ reason: "InternalError", cause })),
|
|
552
|
+
BadArgument: (cause) => Effect.fail(new MultipartError({ reason: "InternalError", cause }))
|
|
553
|
+
})
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
class PersistedFileImpl extends PartBase implements PersistedFile {
|
|
557
|
+
readonly _tag = "PersistedFile"
|
|
558
|
+
|
|
559
|
+
constructor(
|
|
560
|
+
readonly key: string,
|
|
561
|
+
readonly name: string,
|
|
562
|
+
readonly contentType: string,
|
|
563
|
+
readonly path: string
|
|
564
|
+
) {
|
|
565
|
+
super()
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
toJSON(): unknown {
|
|
569
|
+
return {
|
|
570
|
+
_id: "@effect/platform/Multipart/Part",
|
|
571
|
+
_tag: "PersistedFile",
|
|
572
|
+
key: this.key,
|
|
573
|
+
name: this.name,
|
|
574
|
+
contentType: this.contentType,
|
|
575
|
+
path: this.path
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* @since 1.0.0
|
|
582
|
+
* @category fiber refs
|
|
583
|
+
*/
|
|
584
|
+
export const withLimits: {
|
|
585
|
+
/**
|
|
586
|
+
* @since 1.0.0
|
|
587
|
+
* @category fiber refs
|
|
588
|
+
*/
|
|
589
|
+
(
|
|
590
|
+
options: {
|
|
591
|
+
readonly maxParts?: Option.Option<number> | undefined
|
|
592
|
+
readonly maxFieldSize?: FileSystem.SizeInput | undefined
|
|
593
|
+
readonly maxFileSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
594
|
+
readonly maxTotalSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
595
|
+
readonly fieldMimeTypes?: ReadonlyArray<string> | undefined
|
|
596
|
+
}
|
|
597
|
+
): <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
|
598
|
+
/**
|
|
599
|
+
* @since 1.0.0
|
|
600
|
+
* @category fiber refs
|
|
601
|
+
*/
|
|
602
|
+
<A, E, R>(
|
|
603
|
+
effect: Effect.Effect<A, E, R>,
|
|
604
|
+
options: {
|
|
605
|
+
readonly maxParts?: Option.Option<number> | undefined
|
|
606
|
+
readonly maxFieldSize?: FileSystem.SizeInput | undefined
|
|
607
|
+
readonly maxFileSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
608
|
+
readonly maxTotalSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
609
|
+
readonly fieldMimeTypes?: ReadonlyArray<string> | undefined
|
|
610
|
+
}
|
|
611
|
+
): Effect.Effect<A, E, R>
|
|
612
|
+
} = dual(2, <A, E, R>(
|
|
613
|
+
effect: Effect.Effect<A, E, R>,
|
|
614
|
+
options: {
|
|
615
|
+
readonly maxParts?: Option.Option<number> | undefined
|
|
616
|
+
readonly maxFieldSize?: FileSystem.SizeInput | undefined
|
|
617
|
+
readonly maxFileSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
618
|
+
readonly maxTotalSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
619
|
+
readonly fieldMimeTypes?: ReadonlyArray<string> | undefined
|
|
620
|
+
}
|
|
621
|
+
): Effect.Effect<A, E, R> => Effect.provide(effect, withLimitsContext(options)))
|
|
622
|
+
|
|
623
|
+
const withLimitsContext = (options: {
|
|
624
|
+
readonly maxParts?: Option.Option<number> | undefined
|
|
625
|
+
readonly maxFieldSize?: FileSystem.SizeInput | undefined
|
|
626
|
+
readonly maxFileSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
627
|
+
readonly maxTotalSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
628
|
+
readonly fieldMimeTypes?: ReadonlyArray<string> | undefined
|
|
629
|
+
}) => {
|
|
630
|
+
const contextMap = new Map<string, unknown>()
|
|
631
|
+
if (options.maxParts !== undefined) {
|
|
632
|
+
contextMap.set(MaxParts.key, options.maxParts)
|
|
633
|
+
}
|
|
634
|
+
if (options.maxFieldSize !== undefined) {
|
|
635
|
+
contextMap.set(MaxFieldSize.key, FileSystem.Size(options.maxFieldSize))
|
|
636
|
+
}
|
|
637
|
+
if (options.maxFileSize !== undefined) {
|
|
638
|
+
contextMap.set(MaxFileSize.key, Option.map(options.maxFileSize, FileSystem.Size))
|
|
639
|
+
}
|
|
640
|
+
if (options.maxTotalSize !== undefined) {
|
|
641
|
+
contextMap.set(IncomingMessage.MaxBodySize.key, Option.map(options.maxTotalSize, FileSystem.Size))
|
|
642
|
+
}
|
|
643
|
+
if (options.fieldMimeTypes !== undefined) {
|
|
644
|
+
contextMap.set(FieldMimeTypes.key, Chunk.fromIterable(options.fieldMimeTypes))
|
|
645
|
+
}
|
|
646
|
+
return Context.unsafeMake(contextMap)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* @since 1.0.0
|
|
651
|
+
* @category fiber refs
|
|
652
|
+
*/
|
|
653
|
+
export const withLimitsStream: {
|
|
654
|
+
/**
|
|
655
|
+
* @since 1.0.0
|
|
656
|
+
* @category fiber refs
|
|
657
|
+
*/
|
|
658
|
+
(
|
|
659
|
+
options: {
|
|
660
|
+
readonly maxParts?: Option.Option<number> | undefined
|
|
661
|
+
readonly maxFieldSize?: FileSystem.SizeInput | undefined
|
|
662
|
+
readonly maxFileSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
663
|
+
readonly maxTotalSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
664
|
+
readonly fieldMimeTypes?: ReadonlyArray<string> | undefined
|
|
665
|
+
}
|
|
666
|
+
): <A, E, R>(stream: Stream.Stream<A, E, R>) => Stream.Stream<A, E, R>
|
|
667
|
+
/**
|
|
668
|
+
* @since 1.0.0
|
|
669
|
+
* @category fiber refs
|
|
670
|
+
*/
|
|
671
|
+
<A, E, R>(
|
|
672
|
+
stream: Stream.Stream<A, E, R>,
|
|
673
|
+
options: {
|
|
674
|
+
readonly maxParts?: Option.Option<number> | undefined
|
|
675
|
+
readonly maxFieldSize?: FileSystem.SizeInput | undefined
|
|
676
|
+
readonly maxFileSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
677
|
+
readonly maxTotalSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
678
|
+
readonly fieldMimeTypes?: ReadonlyArray<string> | undefined
|
|
679
|
+
}
|
|
680
|
+
): Stream.Stream<A, E, R>
|
|
681
|
+
} = dual(2, <A, E, R>(
|
|
682
|
+
stream: Stream.Stream<A, E, R>,
|
|
150
683
|
options: {
|
|
151
|
-
readonly
|
|
152
|
-
readonly
|
|
684
|
+
readonly maxParts?: Option.Option<number> | undefined
|
|
685
|
+
readonly maxFieldSize?: FileSystem.SizeInput | undefined
|
|
686
|
+
readonly maxFileSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
687
|
+
readonly maxTotalSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
688
|
+
readonly fieldMimeTypes?: ReadonlyArray<string> | undefined
|
|
153
689
|
}
|
|
154
|
-
)
|
|
690
|
+
): Stream.Stream<A, E, R> => Stream.provideSomeContext(stream, withLimitsContext(options)))
|
|
155
691
|
|
|
156
692
|
/**
|
|
157
693
|
* @since 1.0.0
|
|
158
694
|
* @category fiber refs
|
|
159
695
|
*/
|
|
160
|
-
export
|
|
696
|
+
export declare namespace withLimits {
|
|
697
|
+
/**
|
|
698
|
+
* @since 1.0.0
|
|
699
|
+
* @category fiber refs
|
|
700
|
+
*/
|
|
701
|
+
export type Options = {
|
|
702
|
+
readonly maxParts?: Option.Option<number> | undefined
|
|
703
|
+
readonly maxFieldSize?: FileSystem.SizeInput | undefined
|
|
704
|
+
readonly maxFileSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
705
|
+
readonly maxTotalSize?: Option.Option<FileSystem.SizeInput> | undefined
|
|
706
|
+
readonly fieldMimeTypes?: ReadonlyArray<string> | undefined
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* @since 1.0.0
|
|
712
|
+
* @category fiber refs
|
|
713
|
+
*/
|
|
714
|
+
export class MaxParts extends Context.Reference<MaxParts>()("@effect/platform/Multipart/MaxParts", {
|
|
715
|
+
defaultValue: Option.none<number>
|
|
716
|
+
}) {}
|
|
161
717
|
|
|
162
718
|
/**
|
|
163
719
|
* @since 1.0.0
|
|
@@ -174,13 +730,19 @@ export const withMaxParts: {
|
|
|
174
730
|
* @category fiber refs
|
|
175
731
|
*/
|
|
176
732
|
<A, E, R>(effect: Effect.Effect<A, E, R>, count: Option.Option<number>): Effect.Effect<A, E, R>
|
|
177
|
-
} =
|
|
733
|
+
} = dual(
|
|
734
|
+
2,
|
|
735
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>, count: Option.Option<number>): Effect.Effect<A, E, R> =>
|
|
736
|
+
Effect.provideService(effect, MaxParts, count)
|
|
737
|
+
)
|
|
178
738
|
|
|
179
739
|
/**
|
|
180
740
|
* @since 1.0.0
|
|
181
741
|
* @category fiber refs
|
|
182
742
|
*/
|
|
183
|
-
export
|
|
743
|
+
export class MaxFieldSize extends Context.Reference<MaxFieldSize>()("@effect/platform/Multipart/MaxFieldSize", {
|
|
744
|
+
defaultValue: constant(FileSystem.Size(10 * 1024 * 1024))
|
|
745
|
+
}) {}
|
|
184
746
|
|
|
185
747
|
/**
|
|
186
748
|
* @since 1.0.0
|
|
@@ -197,13 +759,19 @@ export const withMaxFieldSize: {
|
|
|
197
759
|
* @category fiber refs
|
|
198
760
|
*/
|
|
199
761
|
<A, E, R>(effect: Effect.Effect<A, E, R>, size: FileSystem.SizeInput): Effect.Effect<A, E, R>
|
|
200
|
-
} =
|
|
762
|
+
} = dual(
|
|
763
|
+
2,
|
|
764
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>, size: FileSystem.SizeInput): Effect.Effect<A, E, R> =>
|
|
765
|
+
Effect.provideService(effect, MaxFieldSize, FileSystem.Size(size))
|
|
766
|
+
)
|
|
201
767
|
|
|
202
768
|
/**
|
|
203
769
|
* @since 1.0.0
|
|
204
770
|
* @category fiber refs
|
|
205
771
|
*/
|
|
206
|
-
export
|
|
772
|
+
export class MaxFileSize extends Context.Reference<MaxFileSize>()("@effect/platform/Multipart/MaxFileSize", {
|
|
773
|
+
defaultValue: Option.none<FileSystem.Size>
|
|
774
|
+
}) {}
|
|
207
775
|
|
|
208
776
|
/**
|
|
209
777
|
* @since 1.0.0
|
|
@@ -220,13 +788,23 @@ export const withMaxFileSize: {
|
|
|
220
788
|
* @category fiber refs
|
|
221
789
|
*/
|
|
222
790
|
<A, E, R>(effect: Effect.Effect<A, E, R>, size: Option.Option<FileSystem.SizeInput>): Effect.Effect<A, E, R>
|
|
223
|
-
} =
|
|
791
|
+
} = dual(
|
|
792
|
+
2,
|
|
793
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>, size: Option.Option<FileSystem.SizeInput>): Effect.Effect<A, E, R> =>
|
|
794
|
+
Effect.provideService(
|
|
795
|
+
effect,
|
|
796
|
+
MaxFileSize,
|
|
797
|
+
Option.map(size, FileSystem.Size)
|
|
798
|
+
)
|
|
799
|
+
)
|
|
224
800
|
|
|
225
801
|
/**
|
|
226
802
|
* @since 1.0.0
|
|
227
803
|
* @category fiber refs
|
|
228
804
|
*/
|
|
229
|
-
export
|
|
805
|
+
export class FieldMimeTypes extends Context.Reference<FieldMimeTypes>()("@effect/platform/Multipart/FieldMimeTypes", {
|
|
806
|
+
defaultValue: constant<Chunk.Chunk<string>>(Chunk.make("application/json"))
|
|
807
|
+
}) {}
|
|
230
808
|
|
|
231
809
|
/**
|
|
232
810
|
* @since 1.0.0
|
|
@@ -243,82 +821,8 @@ export const withFieldMimeTypes: {
|
|
|
243
821
|
* @category fiber refs
|
|
244
822
|
*/
|
|
245
823
|
<A, E, R>(effect: Effect.Effect<A, E, R>, mimeTypes: ReadonlyArray<string>): Effect.Effect<A, E, R>
|
|
246
|
-
} =
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
*/
|
|
252
|
-
export const FileSchema: Schema.Schema<PersistedFile> = internal.FileSchema
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* @since 1.0.0
|
|
256
|
-
* @category schema
|
|
257
|
-
*/
|
|
258
|
-
export const FilesSchema: Schema.Schema<ReadonlyArray<PersistedFile>> = internal.FilesSchema
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* @since 1.0.0
|
|
262
|
-
* @category schema
|
|
263
|
-
*/
|
|
264
|
-
export const SingleFileSchema: Schema.transform<
|
|
265
|
-
Schema.Schema<ReadonlyArray<PersistedFile>>,
|
|
266
|
-
Schema.Schema<PersistedFile>
|
|
267
|
-
> = internal.SingleFileSchema
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* @since 1.0.0
|
|
271
|
-
* @category schema
|
|
272
|
-
*/
|
|
273
|
-
export const schemaJson: <A, I, R>(
|
|
274
|
-
schema: Schema.Schema<A, I, R>,
|
|
275
|
-
options?: ParseOptions | undefined
|
|
276
|
-
) => {
|
|
277
|
-
(field: string): (persisted: Persisted) => Effect.Effect<A, ParseResult.ParseError, R>
|
|
278
|
-
(persisted: Persisted, field: string): Effect.Effect<A, ParseResult.ParseError, R>
|
|
279
|
-
} = internal.schemaJson
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* @since 1.0.0
|
|
283
|
-
* @category schema
|
|
284
|
-
*/
|
|
285
|
-
export const schemaPersisted: <A, I extends Partial<Persisted>, R>(
|
|
286
|
-
schema: Schema.Schema<A, I, R>,
|
|
287
|
-
options?: ParseOptions | undefined
|
|
288
|
-
) => (persisted: Persisted) => Effect.Effect<A, ParseResult.ParseError, R> = internal.schemaPersisted
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* @since 1.0.0
|
|
292
|
-
* @category constructors
|
|
293
|
-
*/
|
|
294
|
-
export const makeChannel: <IE>(
|
|
295
|
-
headers: Record<string, string>,
|
|
296
|
-
bufferSize?: number
|
|
297
|
-
) => Channel.Channel<Chunk.Chunk<Part>, Chunk.Chunk<Uint8Array>, MultipartError | IE, IE, unknown, unknown> =
|
|
298
|
-
internal.makeChannel
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* @since 1.0.0
|
|
302
|
-
* @category constructors
|
|
303
|
-
*/
|
|
304
|
-
export const makeConfig: (headers: Record<string, string>) => Effect.Effect<Multipasta.BaseConfig> = internal.makeConfig
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* @since 1.0.0
|
|
308
|
-
* @category constructors
|
|
309
|
-
*/
|
|
310
|
-
export const toPersisted: (
|
|
311
|
-
stream: Stream.Stream<Part, MultipartError>,
|
|
312
|
-
writeFile?: (path: string, file: File) => Effect.Effect<void, MultipartError, FileSystem.FileSystem>
|
|
313
|
-
) => Effect.Effect<Persisted, MultipartError, FileSystem.FileSystem | Path.Path | Scope.Scope> = internal.toPersisted
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* @since 1.0.0
|
|
317
|
-
*/
|
|
318
|
-
export const collectUint8Array: Channel.Channel<
|
|
319
|
-
never,
|
|
320
|
-
Chunk.Chunk<Uint8Array>,
|
|
321
|
-
unknown,
|
|
322
|
-
unknown,
|
|
323
|
-
Uint8Array
|
|
324
|
-
> = internal.collectUint8Array
|
|
824
|
+
} = dual(
|
|
825
|
+
2,
|
|
826
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>, mimeTypes: ReadonlyArray<string>): Effect.Effect<A, E, R> =>
|
|
827
|
+
Effect.provideService(effect, FieldMimeTypes, Chunk.fromIterable(mimeTypes))
|
|
828
|
+
)
|