@fabrika/app 0.5.4 → 0.6.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.
@@ -13,6 +13,7 @@ import {
13
13
  type EndpointPlan,
14
14
  type EndpointRow,
15
15
  type ExportDescription,
16
+ type FormFileArtifact,
16
17
  isAttachmentShape,
17
18
  type LinkTokenClaims,
18
19
  parseEndpointHeader,
@@ -32,7 +33,15 @@ import type { DataKind } from '@fabrika/db/tables'
32
33
  import type { TokenVerifier } from '@fabrika/jwks'
33
34
  import { createHash, timingSafeEqual } from 'node:crypto'
34
35
  import { z } from 'zod/v4'
35
- import { BROWSER_CONFIG_FILE, joinPath, PAGE_CALLER_FILE, WEBHOOK_BINDING_PARAMETER } from '../contract/defaults'
36
+ import {
37
+ BROWSER_CONFIG_FILE,
38
+ FORM_FILE_LIMITS,
39
+ FORM_INPUT_PART,
40
+ FORM_MULTIPART_TYPE,
41
+ joinPath,
42
+ PAGE_CALLER_FILE,
43
+ WEBHOOK_BINDING_PARAMETER,
44
+ } from '../contract/defaults'
36
45
  import type { Schema } from '../contract/define'
37
46
  import {
38
47
  BadRequestError,
@@ -45,18 +54,18 @@ import {
45
54
  UnauthorizedError,
46
55
  } from '../errors'
47
56
  import type { RequestExecutionContext } from '../fetch'
48
- import type { FileCollections } from '../files/collections'
57
+ import type { FileCollections, ReceivedFormFile } from '../files/collections'
49
58
  import { FileDownload, fileResponse, withFileTreatment } from '../files/treatment'
50
- import { bootstrapPath, bootstrapResponse, githubAccounts, readBoundedBody, verifyGithubSignature, withBootstrap } from './anonymous'
59
+ import { abortedBody, bootstrapPath, bootstrapResponse, githubAccounts, readBoundedBody, verifyGithubSignature, withBootstrap } from './anonymous'
51
60
  import { attachmentResponse } from './attachment'
52
- import { handlerSignal } from './bounded'
61
+ import { handlerSignal, readBoundedBytes } from './bounded'
53
62
  import type { BrowserConfig } from './browser-config'
54
63
  import { CapabilityLimitError, type CapabilityMint, RequestFrame, runInRequest } from './capability'
55
64
  import { CHALLENGE_LIMITS, type ChallengeVerifier } from './challenge'
56
65
  import { connectContext, type ImportWire } from './connect'
57
66
  import type { FailureReports } from './failures'
58
67
  import { ServerFault } from './faults'
59
- import { ACCEPTED_BUCKET, clientBucket, type SubmissionFields, submissionOf } from './form-admission'
68
+ import { ACCEPTED_BUCKET, clientBucket, type Submission, type SubmissionFields, submissionOf } from './form-admission'
60
69
  import { type HeaderPolicy, withHeaderPolicy } from './headers'
61
70
  import type { ContractDatabase } from './implement'
62
71
  import { endpointData, Invocation, noFileCollections, siteData, type SiteDeclaration, undeclaredSql } from './invocation'
@@ -70,6 +79,7 @@ import {
70
79
  type MembershipRequest,
71
80
  type ReadDirectory,
72
81
  } from './membership'
82
+ import { multipartBoundary, MultipartError, multipartParts, type MultipartReader } from './multipart'
73
83
  import { pageCallerOf, presentedBy, type Principal } from './principal'
74
84
  import { project, ProjectionRefusedError } from './project'
75
85
  import { deliveryKey, WEBHOOK_REPLAY_RETENTION_MS, type WebhookPrincipal, webhookSubject } from './webhook'
@@ -167,6 +177,16 @@ export type ServedEndpoint =
167
177
  readonly site?: SiteDeclaration
168
178
  }
169
179
 
180
+ /** A form's file fields and the collections they land in: such a form reads `multipart/form-data`. */
181
+ export interface FormFileAdmission {
182
+ readonly fields: ReadonlyMap<string, FormFileArtifact>
183
+ readonly collections: FileCollections
184
+ /** The bytes all the form's files may write per hour; each file reserves its collection's `maxBytes` first. */
185
+ readonly bytesPerHour: number
186
+ /** How long the whole body may take to arrive. */
187
+ readonly timeoutMs: number
188
+ }
189
+
170
190
  /** What a form admits, decided once at boot from the approved artifact and the application's variables. */
171
191
  export interface FormAdmission {
172
192
  /** Requests per minute from one client, admitted before the body is read. */
@@ -174,6 +194,8 @@ export interface FormAdmission {
174
194
  /** Submissions per minute the form accepts in all: only one that passed its honeypot and its challenge counts. */
175
195
  readonly perForm: number
176
196
  readonly fields: SubmissionFields
197
+ /** The form's file fields; `null` for a form that takes none, whose body is JSON. */
198
+ readonly files: FormFileAdmission | null
177
199
  /** The challenge's verifier, with the secret the application variable holds; `null` without a challenge. */
178
200
  readonly challenge: ChallengeVerifier | null
179
201
  /** The one token the challenge does not record, so it passes again: `fabrika app dev`'s test widget's; `null` otherwise. */
@@ -740,6 +762,10 @@ export class ContractRuntime {
740
762
  if (!(await admissions.take(served.form, clientBucket(request), admission.perClient))) {
741
763
  throw formRateLimited(served.row)
742
764
  }
765
+ // A form with files whose every file is optional also takes the JSON body of a page that sends none.
766
+ if (admission.files !== null && mediaType(request.headers.get('Content-Type')) === FORM_MULTIPART_TYPE) {
767
+ return await this.#multipartForm(served, admission.files, request, exec)
768
+ }
743
769
  const body = await readBoundedBody(request, maxBytesOf(served.row))
744
770
  const submission = submissionOf(strictInput(served.row, served.input, parseJson(body)), admission.fields)
745
771
  if (submission.trapped) {
@@ -760,6 +786,142 @@ export class ContractRuntime {
760
786
  return jsonResponse({ result: { received: true } }, 200)
761
787
  }
762
788
 
789
+ /**
790
+ * A form that takes files reads its `multipart/form-data` body as it streams, in the order of `#form`: its first part
791
+ * is the input as JSON (within the form's `maxBytes`), so the input, the honeypot and the challenge are decided before
792
+ * a byte of any file is read. Each file part then lands in its collection under a pending row of its own, owned by
793
+ * nobody, and the inbox row that names each file's id makes them held in its own transaction. A submission that does
794
+ * not commit deletes every file it received. The whole body arrives within the form's `timeoutMs`.
795
+ */
796
+ async #multipartForm(
797
+ served: Extract<ServedEndpoint, { kind: 'form' }>,
798
+ files: FormFileAdmission,
799
+ request: Request,
800
+ exec: RequestExecutionContext | undefined,
801
+ ): Promise<Response> {
802
+ const { admission } = served
803
+ const boundary = multipartBoundary(request.headers.get('Content-Type'))
804
+ if (boundary === null) {
805
+ throw new HttpError(400, 'admission', `'${served.row.id}' takes multipart/form-data with a boundary`)
806
+ }
807
+ // Each part is bounded on its own as it streams; a declared length past all of them together is refused unread.
808
+ const declaredFiles = [...files.fields.values()]
809
+ const limit = maxBytesOf(served.row) + declaredFiles.reduce((sum, field) => sum + files.collections.maxBytesOf(field.collection), 0)
810
+ + (declaredFiles.length + 1) * MULTIPART_PART_OVERHEAD
811
+ const declared = Number(request.headers.get('Content-Length') ?? '')
812
+ if (Number.isFinite(declared) && declared > limit) {
813
+ throw new HttpError(413, 'too_large', `The body is larger than the ${limit} bytes the form's input and files can hold`)
814
+ }
815
+ const deadline = AbortSignal.any([request.signal, AbortSignal.timeout(files.timeoutMs)])
816
+ const parts = multipartParts(request.body ?? new Blob([]).stream(), boundary, { maxHeaderBytes: FORM_FILE_LIMITS.maxPartHeaderBytes })
817
+ const stop = () => {
818
+ parts.cancel().catch(() => {})
819
+ }
820
+ deadline.addEventListener('abort', stop, { once: true })
821
+ const received: ReceivedFormFile[] = []
822
+ /**
823
+ * `kept` once the inbox row may have committed: an append that threw may have committed before its answer was
824
+ * lost, so its files are left to the sweep, which deletes a pending row's object after an hour and never a held one.
825
+ */
826
+ let kept = false
827
+ try {
828
+ const submission = await this.#multipartInput(served, parts)
829
+ if (submission.trapped) {
830
+ return jsonResponse({ result: { received: true } }, 200)
831
+ }
832
+ if (admission.challenge === null) {
833
+ // `boot` refuses a form with files and no challenge; reaching this is a composition bug.
834
+ throw new ServerFault('contract_inconsistent', `'${served.row.id}' takes files without a challenge`)
835
+ }
836
+ await this.#challenge(served.form, admission.challenge, submission.token, request, admission.repeatableToken)
837
+ const columns = new Map<string, string | null>()
838
+ for (let part = await parts.next(); part !== null; part = await parts.next()) {
839
+ const field = files.fields.get(part.name)
840
+ if (field === undefined || columns.has(part.name) || part.filename === null) {
841
+ await part.body.cancel()
842
+ throw new HttpError(
843
+ 400,
844
+ 'multipart',
845
+ `'${served.row.id}': a part is no file field of the form, a field arrives twice, or a file has no filename`,
846
+ )
847
+ }
848
+ const file = await this.#receiveFormFile(served, files, field.collection, part)
849
+ if (file !== null) {
850
+ received.push(file)
851
+ }
852
+ columns.set(part.name, file?.id ?? null)
853
+ }
854
+ const row: Record<string, unknown> = { ...submission.row }
855
+ for (const [name, field] of files.fields) {
856
+ const id = columns.get(name) ?? null
857
+ if (id === null && field.required) {
858
+ throw new HttpError(400, 'validation', `${name}: the form needs this file`, {
859
+ issues: [{ path: name, code: 'required', message: 'a file is required' }],
860
+ })
861
+ }
862
+ row[name] = id
863
+ }
864
+ const held = { files: received.map(({ id, size, sha256 }) => ({ id, size, sha256 })), holdMs: FORM_FILE_LIMITS.holdMs }
865
+ kept = true
866
+ const appended = await this.#framework().appendToInbox(served.form, row, { bucket: ACCEPTED_BUCKET, limit: admission.perForm }, held)
867
+ if (!appended) {
868
+ kept = false
869
+ throw formRateLimited(served.row)
870
+ }
871
+ } catch (error) {
872
+ throw multipartFailure(error, parts, deadline, request)
873
+ } finally {
874
+ deadline.removeEventListener('abort', stop)
875
+ await parts.cancel()
876
+ if (!kept) {
877
+ await files.collections.releaseFormFiles(received)
878
+ }
879
+ }
880
+ if (served.then !== null) {
881
+ // Without an execution context the kick runs detached; it never rejects.
882
+ const kicked = this.#parts.kick(served.then)
883
+ exec?.waitUntil(kicked)
884
+ }
885
+ return jsonResponse({ result: { received: true } }, 200)
886
+ }
887
+
888
+ /**
889
+ * One file of a submission, within the form's hourly byte budget: it reserves its collection's `maxBytes` before its
890
+ * first byte is written (429 when they no longer fit) and gives back what it did not use. A file that failed keeps
891
+ * its reservation: its bytes may have been written.
892
+ */
893
+ async #receiveFormFile(
894
+ served: Extract<ServedEndpoint, { kind: 'form' }>,
895
+ files: FormFileAdmission,
896
+ collection: string,
897
+ part: { readonly filename: string | null; readonly contentType: string | null; readonly body: ReadableStream<Uint8Array> },
898
+ ): Promise<ReceivedFormFile | null> {
899
+ const admissions = this.#framework().forms
900
+ const reserved = files.collections.maxBytesOf(collection)
901
+ if (!(await admissions.reserveBytes(served.form, reserved, files.bytesPerHour))) {
902
+ await part.body.cancel()
903
+ throw new HttpError(429, 'rate_limited', `'${served.row.id}' takes no more files this hour; try again later`)
904
+ }
905
+ const file = await files.collections.receiveFormFile(served.form, collection, part)
906
+ await admissions.releaseBytes(served.form, reserved - (file?.size ?? 0))
907
+ return file
908
+ }
909
+
910
+ /** The first part of a multipart form: its input, as JSON within the form's `maxBytes`, parsed strictly. */
911
+ async #multipartInput(served: Extract<ServedEndpoint, { kind: 'form' }>, parts: MultipartReader): Promise<Submission> {
912
+ const first = await parts.next()
913
+ if (first === null || first.name !== FORM_INPUT_PART || first.filename !== null) {
914
+ await first?.body.cancel()
915
+ throw new HttpError(400, 'multipart', `'${served.row.id}': the first part is the input as JSON, named '${FORM_INPUT_PART}', without a filename`)
916
+ }
917
+ const limit = maxBytesOf(served.row)
918
+ const bytes = await readBoundedBytes(first.body, { maxBytes: limit })
919
+ if (bytes === null) {
920
+ throw new HttpError(413, 'too_large', `The input is larger than ${limit} bytes`)
921
+ }
922
+ return submissionOf(strictInput(served.row, served.input, parseJson(bytes)), served.admission.fields)
923
+ }
924
+
763
925
  /**
764
926
  * The token verifies for this host and this form, and was not accepted before unless it is the `repeatable` one: a
765
927
  * failure is 400, an unreachable provider 503.
@@ -939,6 +1101,33 @@ function admit(row: CredentialedEndpoint, request: Request): void {
939
1101
  admitContentType(row, request)
940
1102
  }
941
1103
 
1104
+ /** What one part adds to a multipart body beside its bytes: its delimiter and its headers, at most. */
1105
+ const MULTIPART_PART_OVERHEAD = FORM_FILE_LIMITS.maxPartHeaderBytes + 128
1106
+
1107
+ /**
1108
+ * What a failed multipart submission answers: a body that broke the format 400, one that did not arrive in time 408,
1109
+ * and otherwise the failure itself, whichever step met the broken stream first.
1110
+ */
1111
+ function multipartFailure(error: unknown, parts: MultipartReader, deadline: AbortSignal, request: Request): unknown {
1112
+ if (isStructuralError(error) && error.httpStatus < 500) {
1113
+ return error
1114
+ }
1115
+ if (request.signal.aborted) {
1116
+ return abortedBody()
1117
+ }
1118
+ if (deadline.aborted) {
1119
+ return new HttpError(408, 'timeout', 'The submission did not arrive in time')
1120
+ }
1121
+ const broken = error instanceof MultipartError ? error : parts.failure()
1122
+ if (broken?.reason === 'interrupted') {
1123
+ return abortedBody()
1124
+ }
1125
+ if (broken !== null) {
1126
+ return new HttpError(400, 'multipart', `The body is not the multipart/form-data the form reads: ${broken.message}`)
1127
+ }
1128
+ return error
1129
+ }
1130
+
942
1131
  /** The content types an endpoint admits; an anonymous endpoint has no session, so this is its whole admission. */
943
1132
  function formRateLimited(row: EndpointRow): HttpError {
944
1133
  return new HttpError(429, 'rate_limited', `'${row.id}' takes no more submissions right now; try again in a minute`)
@@ -4,6 +4,7 @@
4
4
  import type { TablePolicyArtifact } from '@fabrika/auth-core'
5
5
  import type { DataLayer } from '@fabrika/db'
6
6
  import type { CapabilityAuthority } from '@fabrika/db/capability'
7
+ import type { Session } from '@fabrika/db/framework'
7
8
  import type { EmailSender } from '@fabrika/email'
8
9
  import type { ConnectSnapshot } from '../contract/connect'
9
10
  import type { AnyContract } from '../contract/define'
@@ -16,8 +17,11 @@ import type { ConnectSnapshots, ExactHandlers, HandlersOf } from './types'
16
17
  */
17
18
  export interface ContractDatabase {
18
19
  readonly tables: Readonly<Record<string, TablePolicyArtifact>>
19
- /** The containers an object lies in, by dimension, or `null` when the table that owns `dimension` has no such row. */
20
- locate(dimension: string, id: string): Promise<ReadonlyMap<string, string> | null>
20
+ /**
21
+ * The containers an object lies in, by dimension, or `null` when the table that owns `dimension` has no such row.
22
+ * `using` is a job's transaction, which also sees the object it wrote; a layer of the application's own ignores it.
23
+ */
24
+ locate(dimension: string, id: string, using?: Session): Promise<ReadonlyMap<string, string> | null>
21
25
  }
22
26
 
23
27
  /** `scoped(tables)` of `@fabrika/db`, which `boot` binds, or a `ContractDatabase` of the application's own. */
@@ -267,6 +267,7 @@ export class Invocation {
267
267
  check: boundTo(this.#frame, () => transaction.ended, 'ctx.files'),
268
268
  session: () => transaction.session(),
269
269
  readOnly: kind === 'mutation' && !transaction.readOnly ? null : 'a query reads only; remove files in a mutation',
270
+ attaches: false,
270
271
  }
271
272
  }
272
273
 
@@ -0,0 +1,332 @@
1
+ // A `multipart/form-data` body read part by part as it streams (RFC 7578): what a form that takes files receives. The
2
+ // reader never buffers a part's body: each part's bytes are a stream of their own, which the caller consumes to the
3
+ // end before it asks for the next part, so a form reads its input and checks its challenge before a file's first byte
4
+ // is read. It is strict where browsers are: no preamble, a CRLF or `--` after each delimiter, headers within a bound.
5
+ // Anything else is a `MultipartError`, which the form answers 400.
6
+
7
+ import { Buffer } from 'node:buffer'
8
+
9
+ /**
10
+ * A body that is not the `multipart/form-data` a form reads (`malformed`), or one whose stream failed before its end
11
+ * (`interrupted`: the client closed the connection). Its message names what is wrong, never a byte of it.
12
+ */
13
+ export class MultipartError extends Error {
14
+ override readonly name = 'MultipartError'
15
+ readonly reason: 'malformed' | 'interrupted'
16
+
17
+ constructor(message: string, reason: 'malformed' | 'interrupted' = 'malformed') {
18
+ super(message)
19
+ this.reason = reason
20
+ }
21
+ }
22
+
23
+ export interface MultipartPart {
24
+ /** The `name` of its `Content-Disposition`. */
25
+ readonly name: string
26
+ /** Its `filename`, as sent; `null` when the part has none, which is a plain field. */
27
+ readonly filename: string | null
28
+ /** The media type of its `Content-Type`, lower-cased and without parameters; `null` when it has none. */
29
+ readonly contentType: string | null
30
+ /** Its bytes. Read it to the end, or cancel it, before the next part. */
31
+ readonly body: ReadableStream<Uint8Array>
32
+ }
33
+
34
+ export interface MultipartReader {
35
+ /** The next part, or `null` after the closing delimiter. Rejects while the previous part's body is unread. */
36
+ next(): Promise<MultipartPart | null>
37
+ /** Stops reading the body. */
38
+ cancel(): Promise<void>
39
+ /** Why the body broke the format, wherever the break was met (a part's body included); `null` while it did not. */
40
+ failure(): MultipartError | null
41
+ }
42
+
43
+ /** RFC 2046: 1 to 70 characters of a closed set, the last not a space. */
44
+ const BOUNDARY = /^[0-9A-Za-z'()+_,\-./:=? ]{0,69}[0-9A-Za-z'()+_,\-./:=?]$/
45
+ const CRLF = Buffer.from('\r\n')
46
+ const HEADERS_END = Buffer.from('\r\n\r\n')
47
+
48
+ /** The boundary of a `multipart/form-data` `Content-Type`, or `null` when it is another type or names none. */
49
+ export function multipartBoundary(contentType: string | null): string | null {
50
+ const [type, ...parameters] = splitParameters(contentType ?? '')
51
+ if (type?.toLowerCase() !== 'multipart/form-data') {
52
+ return null
53
+ }
54
+ for (const parameter of parameters) {
55
+ const equals = parameter.indexOf('=')
56
+ if (equals > 0 && parameter.slice(0, equals).trim().toLowerCase() === 'boundary') {
57
+ const value = unquote(parameter.slice(equals + 1).trim())
58
+ return value !== null && BOUNDARY.test(value) ? value : null
59
+ }
60
+ }
61
+ return null
62
+ }
63
+
64
+ /** `a; b="c;d"; e` → `['a', 'b="c;d"', 'e']`: semicolons inside a quoted string do not split. */
65
+ function splitParameters(value: string): string[] {
66
+ const parts: string[] = []
67
+ let current = ''
68
+ let quoted = false
69
+ for (let index = 0; index < value.length; index++) {
70
+ const char = value[index] ?? ''
71
+ if (quoted && char === '\\') {
72
+ current += char + (value[index + 1] ?? '')
73
+ index++
74
+ } else if (char === '"') {
75
+ quoted = !quoted
76
+ current += char
77
+ } else if (char === ';' && !quoted) {
78
+ parts.push(current.trim())
79
+ current = ''
80
+ } else {
81
+ current += char
82
+ }
83
+ }
84
+ parts.push(current.trim())
85
+ return parts
86
+ }
87
+
88
+ /** A parameter value: a token as it is, a quoted string without its quotes and escapes; `null` when malformed. */
89
+ function unquote(value: string): string | null {
90
+ if (!value.startsWith('"')) {
91
+ return value === '' || value.includes('"') ? null : value
92
+ }
93
+ if (value.length < 2 || !value.endsWith('"')) {
94
+ return null
95
+ }
96
+ let text = ''
97
+ for (let index = 1; index < value.length - 1; index++) {
98
+ const char = value[index] ?? ''
99
+ if (char === '\\') {
100
+ index++
101
+ text += value[index] ?? ''
102
+ } else if (char === '"') {
103
+ return null
104
+ } else {
105
+ text += char
106
+ }
107
+ }
108
+ return text
109
+ }
110
+
111
+ interface PartHeaders {
112
+ readonly name: string
113
+ readonly filename: string | null
114
+ readonly contentType: string | null
115
+ }
116
+
117
+ function partHeaders(block: Buffer): PartHeaders {
118
+ let disposition: string | null = null
119
+ let contentType: string | null = null
120
+ for (const line of new TextDecoder('utf-8', { fatal: false }).decode(block).split('\r\n')) {
121
+ const colon = line.indexOf(':')
122
+ if (colon <= 0) {
123
+ throw new MultipartError('a part header is not a name and a value')
124
+ }
125
+ const header = line.slice(0, colon).trim().toLowerCase()
126
+ const value = line.slice(colon + 1).trim()
127
+ if (header === 'content-disposition') {
128
+ if (disposition !== null) {
129
+ throw new MultipartError('a part has two Content-Disposition headers')
130
+ }
131
+ disposition = value
132
+ } else if (header === 'content-type') {
133
+ contentType = value.split(';')[0]?.trim().toLowerCase() || null
134
+ }
135
+ }
136
+ const [kind, ...parameters] = splitParameters(disposition ?? '')
137
+ if (kind?.toLowerCase() !== 'form-data') {
138
+ throw new MultipartError('a part is not form-data (Content-Disposition: form-data; name="…")')
139
+ }
140
+ let name: string | null = null
141
+ let filename: string | null = null
142
+ for (const parameter of parameters) {
143
+ const equals = parameter.indexOf('=')
144
+ const key = equals > 0 ? parameter.slice(0, equals).trim().toLowerCase() : ''
145
+ const value = equals > 0 ? unquote(parameter.slice(equals + 1).trim()) : null
146
+ if (key === 'name' || key === 'filename') {
147
+ if (value === null || (key === 'name' ? name : filename) !== null) {
148
+ throw new MultipartError(`a part's ${key} is malformed or given twice`)
149
+ }
150
+ if (key === 'name') {
151
+ name = value
152
+ } else {
153
+ filename = value
154
+ }
155
+ }
156
+ }
157
+ if (name === null || name === '') {
158
+ throw new MultipartError('a part has no name')
159
+ }
160
+ return { name, filename, contentType }
161
+ }
162
+
163
+ /**
164
+ * Reads `body` as `multipart/form-data` with `boundary`. A part's headers hold at most `maxHeaderBytes`; its body is
165
+ * as long as the sender makes it, so the caller bounds what it reads of each.
166
+ */
167
+ export function multipartParts(body: ReadableStream<Uint8Array>, boundary: string, options: { readonly maxHeaderBytes: number }): MultipartReader {
168
+ const source = body.getReader()
169
+ const delimiter = Buffer.from(`\r\n--${boundary}`)
170
+ // The body starts with the first delimiter, without the CRLF that precedes every later one.
171
+ let buffer: Buffer = Buffer.from(CRLF)
172
+ let ended = false
173
+ /** A part's body is being read: the next part waits until it ended. */
174
+ let reading = false
175
+ /** The closing delimiter was read: there is no further part. */
176
+ let finished = false
177
+ /** Reading stopped before the closing delimiter: no further part can be told from a missing one. */
178
+ let cancelled = false
179
+ let failed: MultipartError | null = null
180
+
181
+ /** A break of the format; once reading was cancelled, the end it meets is the cancellation's, not the sender's. */
182
+ const fail = (message: string, reason: 'malformed' | 'interrupted' = 'malformed'): never => {
183
+ if (cancelled) {
184
+ throw new MultipartError('the body was not read to its end')
185
+ }
186
+ failed = new MultipartError(message, reason)
187
+ throw failed
188
+ }
189
+
190
+ /** Reads more of the body into the buffer; `false` once the body ended. */
191
+ async function fill(): Promise<boolean> {
192
+ if (ended) {
193
+ return false
194
+ }
195
+ let next: Awaited<ReturnType<typeof source.read>>
196
+ try {
197
+ next = await source.read()
198
+ } catch {
199
+ // A closed connection, or a body stream that failed: the sender's, never the application's.
200
+ return fail('the body could not be read to its end', 'interrupted')
201
+ }
202
+ if (next.done) {
203
+ ended = true
204
+ return false
205
+ }
206
+ buffer = buffer.byteLength === 0 ? Buffer.from(next.value) : Buffer.concat([buffer, next.value])
207
+ return true
208
+ }
209
+
210
+ /** At least `size` bytes in the buffer, or the body ended first. */
211
+ async function want(size: number): Promise<boolean> {
212
+ while (buffer.byteLength < size) {
213
+ if (!(await fill())) {
214
+ return false
215
+ }
216
+ }
217
+ return true
218
+ }
219
+
220
+ /** The bytes after a delimiter: `--` closes the body, CRLF starts the next part's headers. */
221
+ async function afterDelimiter(): Promise<'part' | 'end'> {
222
+ if (!(await want(2))) {
223
+ return fail('the body ends inside a delimiter')
224
+ }
225
+ const marker = buffer.subarray(0, 2).toString('latin1')
226
+ buffer = buffer.subarray(2)
227
+ if (marker === '--') {
228
+ return 'end'
229
+ }
230
+ if (marker !== '\r\n') {
231
+ return fail('a delimiter is followed by neither CRLF nor --')
232
+ }
233
+ return 'part'
234
+ }
235
+
236
+ async function headers(): Promise<PartHeaders> {
237
+ for (;;) {
238
+ const end = buffer.indexOf(HEADERS_END)
239
+ if (end > options.maxHeaderBytes || (end < 0 && buffer.byteLength > options.maxHeaderBytes)) {
240
+ return fail(`a part's headers are longer than ${options.maxHeaderBytes} bytes`)
241
+ }
242
+ if (end >= 0) {
243
+ const block = buffer.subarray(0, end)
244
+ buffer = buffer.subarray(end + HEADERS_END.byteLength)
245
+ try {
246
+ return partHeaders(block)
247
+ } catch (error) {
248
+ return fail(error instanceof Error ? error.message : 'a part header is malformed')
249
+ }
250
+ }
251
+ if (!(await fill())) {
252
+ return fail("the body ends inside a part's headers")
253
+ }
254
+ }
255
+ }
256
+
257
+ function partBody(): ReadableStream<Uint8Array> {
258
+ return new ReadableStream<Uint8Array>({
259
+ async pull(controller) {
260
+ try {
261
+ for (;;) {
262
+ const at = buffer.indexOf(delimiter)
263
+ if (at >= 0) {
264
+ if (at > 0) {
265
+ controller.enqueue(new Uint8Array(buffer.subarray(0, at)))
266
+ }
267
+ buffer = buffer.subarray(at + delimiter.byteLength)
268
+ reading = false
269
+ controller.close()
270
+ return
271
+ }
272
+ // Keep what could be the start of the delimiter; everything before it is the part's.
273
+ const safe = buffer.byteLength - (delimiter.byteLength - 1)
274
+ if (safe > 0) {
275
+ controller.enqueue(new Uint8Array(buffer.subarray(0, safe)))
276
+ buffer = buffer.subarray(safe)
277
+ return
278
+ }
279
+ if (!(await fill())) {
280
+ fail('the body ends inside a part')
281
+ }
282
+ }
283
+ } catch (error) {
284
+ controller.error(error)
285
+ }
286
+ },
287
+ async cancel() {
288
+ cancelled = true
289
+ await source.cancel().catch(() => {})
290
+ },
291
+ })
292
+ }
293
+
294
+ let started = false
295
+ return {
296
+ async next(): Promise<MultipartPart | null> {
297
+ if (finished) {
298
+ return null
299
+ }
300
+ if (cancelled) {
301
+ throw new MultipartError('the body was not read to its end')
302
+ }
303
+ if (failed !== null) {
304
+ throw failed
305
+ }
306
+ if (reading) {
307
+ throw new MultipartError("the previous part's body was not read to its end")
308
+ }
309
+ if (!started) {
310
+ started = true
311
+ if (!(await want(delimiter.byteLength)) || buffer.indexOf(delimiter) !== 0) {
312
+ return fail('the body does not start with its boundary')
313
+ }
314
+ buffer = buffer.subarray(delimiter.byteLength)
315
+ }
316
+ if ((await afterDelimiter()) === 'end') {
317
+ finished = true
318
+ // The epilogue is the sender's; nothing reads it.
319
+ await source.cancel().catch(() => {})
320
+ return null
321
+ }
322
+ const found = await headers()
323
+ reading = true
324
+ return Object.freeze({ ...found, body: partBody() })
325
+ },
326
+ async cancel(): Promise<void> {
327
+ cancelled = !finished
328
+ await source.cancel().catch(() => {})
329
+ },
330
+ failure: () => failed,
331
+ }
332
+ }