Skip to content

Native @aws-sdk/client-s3 (+ s3-request-presigner) and fastify multipart/form-data parsing #551

Closed

Description

@proggeramlug

Context

Porting sharity-api (Express + Mongoose, ~23k LoC) to Perry. Two
adjacent gaps block every upload flow: server-side multipart parsing
and AWS S3.

Multipart / form-data on fastify

The Fastify server in perry-ext-fastify handles JSON bodies, but every
upload route in the target app is multipart/form-data — avatars, cover
photos, item images. Need a multer-equivalent or fastify-multipart-style
plugin with:

  • Disk and memory storage modes
  • File size limits (limits: { fileSize: 5 * 1024 * 1024 })
  • MIME filter (fileFilter: (file) => /^image\//.test(file.mimetype))
  • Multiple field names (avatar, image, cover-photo)
  • Generated filenames (UUID + original extension)
  • Stream the file body without buffering the whole multipart payload in
    memory

AWS S3

Sharity-api uses S3 as the canonical image store. Local disk fallback
exists but isn't viable in production. Surface needed:

  • @aws-sdk/client-s3: PutObjectCommand, GetObjectCommand,
    DeleteObjectCommand, HeadObjectCommand
  • @aws-sdk/s3-request-presigner: getSignedUrl(command, {expiresIn})
  • Region + credential resolution from env (AWS_REGION,
    AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_BUCKET_NAME)
  • ACL support (public-read is what the existing app uses)

Acceptance

A fastify route that accepts a multipart upload, streams it to S3 with
PutObjectCommand, and returns a presigned GET URL for the uploaded key.
Compiles and runs against real S3 (or LocalStack) under perry run.

Why both in one issue

The two together are how every upload endpoint in a typical Node API is
wired. Splitting them lets the multipart side ship without S3 (still
useful for disk-only setups), but the issue tracks both because
sharity-api needs both.

Activity

  1. proggeramlug commented on May 7, 2026

    @proggeramlug
    ContributorAuthor

    Researched the pure-JS S3 ecosystem before sketching a perry-ext-s3 Rust binding. The best fit is @bradenmacdonald/s3-lite-client (MIT, zero deps, ~1.9k LoC across 4 runtime files).

    Audited it against Perry's TS-subset:

    • ✅ No eval / new Function / dynamic imports
    • ✅ No Symbol(...) / Proxy / WeakMap / Reflect
    • ✅ No decorators / lookbehind regex / prototype manipulation / computed property keys
    • ✅ No Node-specific Buffer / node:stream / node:crypto — Web APIs only

    Surface coverage: PutObject, GetObject, DeleteObject, statObject (Head), copyObject, listObjects, presignedGetObject / presignedPutObject / presignPostV4, multipart streaming uploads, ACL (x-amz-acl: private | public-read | …). Matches everything #551 lists.

    Two stdlib gaps block native compilation today:

    Both are general-purpose primitives many other packages will need. Read paths (getObject, statObject, deleteObject, listObjects, presignedGetObject, presignedPostObject) hit only #561; the streaming upload path needs #562 too.

    With both landed, s3-lite-client should compile via perry.compilePackages with no patches. Bonus: it speaks any S3-compatible endpoint (R2, MinIO, B2, Spaces, Supabase, LocalStack) for free — a perry-ext-s3 Rust binding would lock you into AWS-only unless endpoint flexibility is re-implemented.

    Multipart/form-data parsing on fastify is the orthogonal half of #551 and not addressed here — that's @fastify/multipart (busboy-based) territory, separate research.

  2. proggeramlug commented on May 7, 2026

    @proggeramlug
    ContributorAuthor

    First compile attempt at v0.5.655 (post #561 + #562) — got 9 of 10 modules through HIR + codegen. Three new gaps surfaced underneath, all with reproducible repros:

    With those three landed, this should compile + run for at least the read paths (Get/Head/Delete/List/presigned*).

  3. proggeramlug commented on May 8, 2026

    @proggeramlug
    ContributorAuthor

    v0.5.663 (post #567 + #568 + #569): the package compiles and links cleanly. Read-path runtime exposes three more gaps, all with reproducible repros:

    All three are general-purpose gaps that will hit any modern TS-style npm package, not just s3-lite. With them landed I'll re-test.

  4. proggeramlug commented on May 8, 2026

    @proggeramlug
    ContributorAuthor

    v0.5.664 (post #572 + #573 + #574): the package compiles, links, runs, and produces a presigned URL end-to-end. Constructor works, all imports resolve, the SigV4 chain runs without crashing, and a URL string comes back. Big jump.

    The URL is structurally correct but has two correctness bugs:

    .../hello.txt?%5Bobject%20Object%5D=&X-Amz-Algorithm=AWS4-HMAC-SHA256&...&X-Amz-SignedHeaders=&X-Amz-Signature=
                    ^^^^^^^^^^^^^^^^^^^^                                              ^^^empty^^^   ^^^empty^^^
    

    With those two landed, the URL should match bun byte-for-byte and authenticate against real S3. Read paths (Get/Head/Delete/List/presign) should then work. Upload path needs the cross-module TsParamProp slot bug separately — that one isn't reached yet by the smoke test.

  5. added 2 commits that reference this issue on May 8, 2026
  6. proggeramlug commented on May 8, 2026

    @proggeramlug
    ContributorAuthor

    v0.5.665 (post #575 + #576): the produced presigned URL is now structurally identical to bun byte-for-byte except the signature itself. Same query params, same order, same encoding. The 64-char X-Amz-Signature=... field is the only mismatch.

    Compared:

    perry: ...&X-Amz-Signature=0000000000000000000000000000000000000000000000000000000000000000
    bun:   ...&X-Amz-Signature=2426c40ac56a2a84c1e6c73fb64db9a134e694b8c1b8a07b5d53373d6cd05e34
    

    Two new issues, both in the typed-array runtime:

    Fix either of these alone won't unblock — both have to land. With both, the resulting URL should authenticate against real S3.

  7. added a commit that references this issue on May 8, 2026
  8. proggeramlug commented on May 8, 2026

    @proggeramlug
    ContributorAuthor

    v0.5.668 (post #578 + #579): typed-array iteration + ArrayBuffer aliasing both verified working. The s3-lite presigned URL would now match bun byte-for-byte EXCEPT a separate codegen regression from v0.5.667 (#536) blocks the compile:

    Once #580 lands the read path should compile cleanly. The byte-for-byte URL match is essentially guaranteed since (a) every step up to the signature is already byte-identical and (b) the typed-array fix verifies the HMAC bytes pass through correctly to bin2hex.

  9. added a commit that references this issue on May 8, 2026
  10. proggeramlug commented on May 8, 2026

    @proggeramlug
    ContributorAuthor

    v0.5.676 (post #580): the package compiles, links, runs, and produces a presigned URL with a real 64-char HMAC signature. Every URL field except the signature itself is byte-identical to bun.

    perry: ...&X-Amz-Signature=50f0c3615b71fc42d0c412a30c500578d04068bfc5a5c5ae21ed61f7271109c3
    bun:   ...&X-Amz-Signature=2426c40ac56a2a84c1e6c73fb64db9a134e694b8c1b8a07b5d53373d6cd05e34
    

    Both well-formed (32-byte HMAC), so the crypto chain works end-to-end. They differ because the canonical request's path segment is corrupted — instrumented signing.ts to dump it:

    perry path: %00%00%00%00%00%80G%40%00%00%00%00%00%00%5D%40%00%00%00%00%00%40
    bun path:   /test-bucket/hello.txt
    

    Root cause traced to a separate runtime bug, filed as:

    s3-lite calls TextEncoder twice in the signing chain (once in awsUriEncode, once via sha256digestHex), so the canonical request, string-to-sign, and final HMAC all derive from corrupted bytes. Once #584 lands the URL should be byte-identical to bun.

  11. added a commit that references this issue on May 8, 2026
  12. proggeramlug commented on May 8, 2026

    @proggeramlug
    ContributorAuthor

    v0.5.690 (post #584): TextEncoder produces correct bytes now, the canonical request's URI segment is correct (`/test-bucket/hello.txt`), and the signature is a real well-formed HMAC. URL is byte-identical to bun except the signature itself:

    perry: ...&X-Amz-Signature=113bf0800ff0d532d08dfd9877b16beef5cd427b3765e3a7f5e5dd1dbf05f2ee
    bun:   ...&X-Amz-Signature=2426c40ac56a2a84c1e6c73fb64db9a134e694b8c1b8a07b5d53373d6cd05e34
    

    Instrumented signing.ts again to dump the canonical request. The query-string segment of the canonical request is empty, even though the URL has 5 X-Amz-* params:

    perry canonical: "GET\n/test-bucket/hello.txt\n\nhost:s3.us-east-1.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD"
                                  empty segment ↑
    

    Traced to a separate runtime bug:

    With #587 landed the URL should be byte-identical to bun and authenticate against real S3.

  13. proggeramlug commented on May 8, 2026

    @proggeramlug
    ContributorAuthor

    v0.5.692 (post #587): The presigned URL is byte-for-byte identical to bun's output.

    perry: https://s3.us-east-1.amazonaws.com/test-bucket/hello.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260508%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260508T120000Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=2426c40ac56a2a84c1e6c73fb64db9a134e694b8c1b8a07b5d53373d6cd05e34
    bun:   https://s3.us-east-1.amazonaws.com/test-bucket/hello.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260508%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260508T120000Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=2426c40ac56a2a84c1e6c73fb64db9a134e694b8c1b8a07b5d53373d6cd05e34
    

    @bradenmacdonald/s3-lite-client compiles natively under `perry.compilePackages` (no patches), the full SigV4 signing chain runs end-to-end (TextEncoder → SHA-256 hashing → HMAC-SHA-256 key derivation → final HMAC), and produces a valid AWS-authenticatable URL.

    Read paths (`presignedGetObject` / `presignedPostObject` / `getObject` / `statObject` / `deleteObject` / `listObjects`) should all work — they share this exact signing chain. The byte-match against bun is the strongest possible verification short of hitting real S3.

    Total path from #551 filed → byte-identical URL: 12 issues filed and closed (#561 #562 #567 #568 #569 #572 #573 #574 #575 #576 #578 #579 #580 #584 #587), spanning Web Crypto, Web Streams subclassing, class destructure params, Error inheritance, namespace imports, URLSearchParams, Headers iteration, typed-array iteration + ArrayBuffer aliasing, TextEncoder, and chained array-method evaluation. Each one a general-purpose gap, not s3-lite-specific.

    Multipart upload path is the orthogonal remaining concern (would exercise WritableStream subclassing + the cross-module TsParamProp slot bug) — separate test from the read-path completion captured here.

  14. proggeramlug commented on May 8, 2026

    @proggeramlug
    ContributorAuthor

    Documentation added — `@bradenmacdonald/s3-lite-client` is now listed as a known-working package and has its own usage section in the HTTP & Networking docs:

    • `docs/src/stdlib/http.md` — new "AWS S3 / S3-Compatible Object Storage" section between WebSocket and Next Steps. Covers `compilePackages` config, all the major operations (presigned URLs, putObject, getObject, statObject, deleteObject, listObjects with for-await), the S3-compatible endpoint table (R2 / MinIO / B2 / Spaces / Supabase / LocalStack), and a pointer to why `@aws-sdk/client-s3` v3 isn't an option.
    • `docs/src/packages/porting.md` — added to the "Known-working packages" list alongside hono, with a one-line summary and a link back to the http page.

    Both docs cross-reference this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions