release: promote dev to master (issue batch, master reconcile, styles TS6 fix, #364/#365, #351 unblock) - #369
Merged
Conversation
…#342) core.addFiles() emits `restriction-failed` and then rethrows. The rethrow is deliberate — a direct `await core.addFiles()` caller narrows on it — but the react headless surface reaches addFiles from three fire-and-forget DOM callbacks that have nobody to rethrow to: - getInputProps().onChange -> `void addFiles(...)` - getDropzoneProps().onDrop -> `void dragDrop.handleDrop(...)` - useUpupUpload's DragDropDeps.setFiles -> bare `core.addFiles(files)`, which the controller invokes un-awaited for BOTH drop and paste So every ordinary user-facing restriction failure (wrong type, too large, over the limit) surfaced a second time as an unhandled promise rejection and polluted error reporting. Fix: swallow the rejection on those three paths only. Nothing is lost — core emits `restriction-failed` carrying the identical error BEFORE it throws, so the event bus is the surviving channel. `await addFiles()` still rejects. Census: the visual panels in all six frameworks were never affected — their setFiles routes through createUploaderController's handleSetSelectedFiles, which try/catches and never rejects (vue's useUploaderController already documents this as "the void contract"). This headless dep was the sole call site reaching core.addFiles bare, so core's DragDropController is untouched. RED before the fix (tests/prop-getters-unhandled-rejection.test.ts): Test Files 1 failed (1) Tests 4 failed | 1 passed (5) AssertionError: expected [ ...(1) ] to deeply equal [] - [] + [ + UpupValidationError { + "message": "File type \"text/plain\" is not accepted", + "code": "TYPE_MISMATCH", + "reason": "TYPE_MISMATCH", + }, + ] The one test that passed RED is "leaves a DIRECT await core.addFiles() rejection intact" — pinning that the rethrow itself is preserved. GREEN after: 5 passed (5); full @upupjs/react suite 611 passed (69 files); `pnpm --filter @upupjs/react typecheck` exit 0.
The three getters each merged `overrides` differently, so what happened to a
key depended on which getter you passed it to:
- getDropzoneProps composed the four drag handlers and DROPPED every other
override key — className/style/id silently vanished
- getRootProps spread overrides, then wrote its own keys over the top,
including a literal `aria-describedby: undefined` that deleted a caller's
value
- getInputProps spread overrides, then replaced `style` wholesale with
`{ display: 'none' }`
All three now follow ONE rule:
1. `...overrides` is spread FIRST — anything you pass survives.
2. Getter-OWNED keys are applied after and win, limited to values derived
from live core state plus the ones without which the element stops being
an uploader element:
root -> aria-busy
dropzone -> aria-dropeffect
input -> type, multiple, accept, style.display
3. Event handlers are COMPOSED, never replaced: the getter's handler runs
first, then the caller's (the order composeEventHandlers already used, so
no behavior change for existing handler overrides).
4. `style` is MERGED, not replaced.
5. role / aria-label / tabIndex / aria-hidden become overridable DEFAULTS.
`accept` is claimed only when core actually declares `allowedFileTypes` —
writing `undefined` unconditionally would delete a caller's own accept, which
is the same silent-drop bug this contract exists to end.
Headless-surface only: createPropGetters has exactly two call sites (itself and
useUpupUpload), the visual components render their own input, so no DOM
contract string or parity fixture is touched.
RED before the fix (tests/prop-getters-override-contract.test.ts):
Test Files 1 failed (1)
Tests 7 failed | 14 passed (21)
x getDropzoneProps keeps non-handler override keys
expected undefined to be 'my-zone'
x getRootProps does not clobber an aria-describedby override with undefined
expected undefined to be 'help-text'
x an accept override survives when core declares no file-type filter
expected undefined to be '.csv'
x getInputProps keeps override style keys and still hides the input
expected { display: 'none' } to deeply equal { Object (position, width, ...) }
x root role and aria-label are overridable
expected 'application' to be 'group'
x dropzone role, aria-label and tabIndex are overridable
expected 'region' to be 'button'
x input tabIndex and aria-hidden are overridable
expected -1 to be +0
GREEN after: 21 passed (21); full @upupjs/react suite 632 passed (70 files) —
the pre-existing prop-getters tests still pass unchanged, since every default
is preserved when no override is supplied. typecheck exit 0.
Docs: apps/landing/content/docs/guides/headless.mdx gains a "Prop getters and
overrides" section stating the five rules and tabulating the owned keys.
The error-handling docs tell you to narrow failures with `instanceof UpupError`
and `UpupErrorCode`, and `useUpupUpload().error` is already typed
`UpupError | null` — but neither name was reachable from @upupjs/react, so a
react-only consumer had to add a direct @upupjs/core dependency purely to type
a catch block.
@upupjs/react's entry now re-exports, verbatim from @upupjs/core, 8 runtime
names:
UpupErrorCode
UpupError
UpupAuthError
UpupNetworkError
UpupValidationError
UpupQuotaError
UpupStorageError
UpupConfigError
plus the type-only `RestrictionFailedReason`. React's pinned public list goes
27 -> 35 runtime names; the added names are exactly the 8 above.
@upupjs/preact and @upupjs/next inherit them: both are a one-line
`export * from '@upupjs/react'`, and both pins assert equality WITH react's
list rather than duplicating it, so they follow automatically. Verified at the
runtime level rather than trusting the equality pin — probing preact's BUILT
bundle after rebuilding core -> react -> preact:
preact dist total exports: 35
missing: []
instanceof UpupError from preact dist: true
New tests/error-exports.test.ts pins IDENTITY, not just presence: a re-export
that produced a second class object would satisfy the name pin while silently
breaking every `instanceof` across the package boundary. It asserts each class
is the same object core exports, and that an error raised by the engine itself
narrows through react-only imports.
RED before the change:
Test Files 2 failed (2)
Tests 11 failed (11)
AssertionError: expected undefined to be type of 'function'
AssertionError: expected undefined to be { AUTH_EXPIRED: 'AUTH_EXPIRED', ...(22) }
AssertionError: The instanceof assertion needs a constructor but undefined was given.
AssertionError: expected [ Array(27) ] to deeply equal [ Array(35) ]
GREEN after: react 642 passed (71 files), preact 22 passed, next 23 passed;
typecheck exit 0 for react, preact and next.
Deliberately NOT included: `uploadErrorFromResponse`. Despite the docs showing
`import { uploadErrorFromResponse } from '@upupjs/core'`, it is exported only
from @upupjs/core/internal, not core's public entry — promoting an internal to
the public allow-list is a separate API decision. The stale docs import is
reported to the issue batch rather than fixed here.
`Promise<unknown> | void` tripped @typescript-eslint/no-invalid-void-type
("void is not valid as a constituent in a union type"). The callers' return
types are themselves void-unions (`Promise<void> | void` from
PropGetterDeps.addFiles), so no narrower parameter type accepts them — take
`unknown` and keep the existing duck-typed thenable check.
Caught by `pnpm --filter @upupjs/react lint`, which the pre-commit hook does
not run (it runs oxlint + prettier + unit suites; eslint is a pre-push gate).
react: lint exit 0, typecheck exit 0, 642 passed (71 files).
CLAUDE.md names `UpupError + subclasses + uploadErrorFromResponse` as the one
public error taxonomy, and apps/landing/content/docs/api-reference/
error-codes.mdx:240 already instructs users to
`import { uploadErrorFromResponse } from '@upupjs/core'` — but the curated
allow-list never caught up with that documented intent, so the import did not
resolve.
CORRECTION to the earlier report on this issue: it was NOT "exported only from
./internal". It was exported from NEITHER entry. Verified by probing the built
artifacts directly rather than trusting the pins:
public entry has uploadErrorFromResponse: false
internal entry has uploadErrorFromResponse: false
Core's own pin carried a comment asserting "(via ./internal)", which was simply
wrong; that comment is corrected here. All four in-tree call sites
(direct-upload, multipart-upload, server-credentials, server-transfer) reach it
by relative import inside core's own src, so nothing consumed it through the
internal subpath and this promotion is purely additive — ./internal and its pin
are untouched, and no alias or second binding is introduced.
Surfaces:
- @upupjs/core `.` : 51 -> 52 runtime names (+uploadErrorFromResponse)
- @upupjs/react : 35 -> 36, re-exported alongside the taxonomy so
framework-only consumers get the full documented
error toolkit without a direct core dependency
- @upupjs/preact / @upupjs/next inherit (both are `export * from
'@upupjs/react'`; both pins assert equality with react's list)
Verified in preact's BUILT bundle after rebuilding core -> react -> preact,
not merely via the equality pin:
preact dist total exports: 36
missing: []
uploadErrorFromResponse identity === core: true
built err instanceof preact UpupError: true
built err instanceof preact UpupStorageError: true
built err.code: STORAGE_ERROR
RED before the change:
core — expected [ 'ACCEPT_PRESETS', ...(50) ] to deeply equal [ ...(51) ]
react — expected [ Array(35) ] to deeply equal [ Array(36) ]
expected undefined to be type of 'function'
Tests 1 failed | 1 passed (core), 3 failed | 10 passed (react)
GREEN after: core 1585 passed (134 files), react 644 (71), preact 22, next 23;
typecheck exit 0 for all four; lint 20/20; size exit 0; knip exit 0.
NOT included, deliberately: the `UploadErrorFromResponseArgs` parameter
interface stays unexported. Inline object-literal call sites type-check
structurally, so the ergonomic gap only affects someone building the args
object separately — widening core's public TYPE surface is its own decision.
…pes/node
`readBody` returned the assembled Node `Buffer` straight into
`toWebRequest({ body })`. Under @types/node >=22 a Buffer types as
`Buffer<ArrayBufferLike>`, which is not assignable to `BodyInit`, so the
dependabot lockfile regen in PR #351 turned both `@upupjs/next:typecheck`
and `@upupjs/next:build` red:
src/pages-handler.ts(59,17): error TS2322: Type 'Buffer<ArrayBufferLike> |
undefined' is not assignable to type 'BodyInit | null | undefined'.
Type 'Buffer<ArrayBufferLike>' is not assignable to type 'BodyInit | null
| undefined'.
Copy into a fresh `Uint8Array` (same bytes, backed by a real ArrayBuffer) and
annotate the helper as `RequestInit['body']` — a bare `Uint8Array` annotation
means `Uint8Array<ArrayBufferLike>` and reproduces the identical error:
packages/next/src/pages-handler.ts(66,17): error TS2322: Type
'Uint8Array<ArrayBufferLike> | undefined' is not assignable to type
'BodyInit | null | undefined'.
RED before the fix (packages/next/src/__tests__/pages-handler.spec.ts):
FAIL src/__tests__/pages-handler.spec.ts > createUpupPagesHandler > hands
the body to the bridge as a plain Uint8Array, not a Node Buffer
AssertionError: expected Buffer[ 123, 34, 110, 97, 109, ... ] to be an
instance of Uint8Array
> 135| expect(bridged.body).toBeInstanceOf(Uint8Array)
Verified both ways: `pnpm --filter @upupjs/next typecheck` (TS 5.3.3 /
@types/node 20) exit 0, and tsc 5.9.3 with typeRoots pinned to
@types/node 26.1.2 over the real source exit 0.
Two gaps around serving stored objects, both hit while migrating an app that
serves gated downloads.
1. There was no way to sign a GET for an EXISTING key. The only signed-GET
producer lived inside the upload flow, so "give me a fresh URL for a key I
stored last month" meant standing up a second handler with an identity
keyStrategy and using half of it. `getDownloadUrl(config, key, opts?)` is
that operation on its own — no handler, no route, no token. It takes the
storage slice of UpupServerConfig (pass the whole config, or `{ storage }`),
and throws UpupConfigError for a storage.type with no S3 API.
2. The download-URL TTL was hardcoded at 3 days, so an app that issued
15-minute links for gated content silently got 3-day links. The new
`downloadUrlExpiresIn` (seconds) on UpupServerConfig sets it for every
signed GET the server hands out: `downloadUrl` on /presign and
/multipart/complete, and `url` on /files/:provider/transfer. Expiry
resolves per-call `expiresIn` -> `config.downloadUrlExpiresIn` -> 3 days.
The upload URL's own 1-hour expiry is untouched.
Public API pin (packages/server/tests/public-api.test.ts) gains ONE runtime
name: `getDownloadUrl`. New exported types: `UpupStorageConfig` (the storage
object, extracted from the inline UpupServerConfig shape),
`DownloadUrlConfig`, `GetDownloadUrlOptions`.
The non-S3 provider guard moved out of handler.ts into src/storage.ts
(`assertS3Storage`) so the handler's construct-time check and getDownloadUrl's
per-call check are the same code and the same message, not two copies.
RED before the implementation (packages/server/tests/download-url.test.ts):
FAIL tests/download-url.test.ts [ tests/download-url.test.ts ]
Error: Cannot find module '/src/download-url' imported from
.../packages/server/tests/download-url.test.ts
> 40| import { getDownloadUrl } from '../src/download-url'
Gates: server unit suite 276 passed | 35 skipped, typecheck (src + test tree)
exit 0, docs:links:check / docs:api-sync:check (my entry) /
docs:snippets:coverage / docs:snippets:check all OK.
…eforeUpload (#338) A deployment whose storage endpoint is not browser-reachable — a private MinIO behind a same-origin proxy route, a docker-internal hostname in local dev, a VPC-only endpoint — could not use the shipped handlers at all: `hooks` had no response-side seam, so there was no way to rewrite `uploadUrl` before it left the server. One hook, `onPresignResponse`, closes that. It fires on the three presign-side responses, discriminated by `ctx.phase`: presign POST /presign PresignedUrlResponse multipart-init POST /multipart/init MultipartInitResponse + token multipart-sign-part POST /multipart/sign-part MultipartSignPartResponse One hook name rather than three, because the rewrite is the same operation each time and covering only /presign would leave multipart uploads pointed at the unreachable host. Returning an object replaces the payload; returning nothing keeps it. ctx is { req, phase, key, metadata?, userId } — `key` is always the key that is IN the payload (on sign-part it comes from the VERIFIED token, never the client), and `metadata` is absent on sign-part, which sees only a token and a part number. Trust model unchanged. The hook runs after every auth, policy, and token check and after the upload token is issued; it cannot alter a status code or turn a rejection into a success, and a request that would 401/403 never reaches it. Two tests pin that directly ("never runs for a request rejected before the route", "never runs for a request the auth gate rejected"). Responses still go out through the Responder, so response-contract.test.ts is untouched. Also: `onBeforeUpload` throwing an UpupError now serializes that error's message and code into the 403, so a quota check can say "Storage limit exceeded — upgrade to keep uploading" instead of the opaque "Upload rejected". Returning `false` keeps the generic body byte-for-byte. Any NON-UpupError throw is re-thrown and stays a generic 500 with the cause going only to onError — pinned by a test asserting the thrown message does not appear in the response body. New exported types (types only — the runtime public-API pin is unchanged): PresignResponsePhase, PresignResponseContext, PresignResponseBody, PresignResponseRewrite. RED before the implementation (6 of 11 cases in packages/server/tests/presign-response-hook.test.ts): × replaces the /presign payload when the hook returns an object expected 'https://internal-minio:9000/bucket/u1…' to be 'https://app.example.com/api/s3/bucket…' × replaces the /multipart/init payload, token included expected undefined to be 'eu-west-1' × replaces the /multipart/sign-part payload × reports phase, key, metadata and userId per response × surfaces an UpupError thrown by the hook with its message and code × surfaces it on /multipart/init too The two UpupError cases were 500s before, with the hook's message reaching only the logs: [upup:server] {"route":"presign",…,"status":500,"code":"STORAGE_ERROR", "message":"Internal error","error":{"name":"UpupQuotaError","message": "Storage limit exceeded — upgrade to keep uploading",…}} Gates: server unit suite 287 passed | 35 skipped (handler-extended and trust-model unmodified), typecheck (src + test tree) exit 0, docs:links:check OK.
…adata (#337) `storage` was a single static object, so `createUpupHandler` only fit single-bucket apps — an app routing images / quarantined-but-unscanned documents / general documents to three buckets with their own credentials and endpoints had to keep a hand-rolled presign route. It now also accepts a resolver: storage: ctx => ctx.storageId ? byIdentity(ctx.storageId) : BUCKETS[classify(ctx.metadata)] ctx is { req, phase, userId, metadata?, fileName?, contentType?, size?, storageId? }, and every route resolves through it: presign, multipart init, the three multipart continuations, and drive-transfer (which resolves AFTER the drive reports the real name/type/size, not from the client's claim). The token carrier (the hard part of this change) ------------------------------------------------ A multipart upload picks its bucket at init, but sign-part/complete/abort arrive later with only a token — none of the metadata the decision came from. Re-running the resolver blind would send them elsewhere; accepting a client-supplied hint would let anyone redirect a continuation into a bucket of their choosing. So init stamps an opaque STORAGE IDENTITY into the HMAC-signed upload token (new optional `sid`), and each continuation hands it back to the resolver as `ctx.storageId`. The server then re-derives the identity of whatever the resolver returned and answers 403 AUTH_DENIED on a mismatch — a resolver that ignores storageId fails closed instead of writing parts into the wrong bucket. No unsigned storage hint is ever accepted. The identity is SHA-256(bucket, endpoint, region), truncated — deterministic (so a token issued by one worker verifies on any other) and free of credentials, so rotating an access key does not strand in-flight uploads and nothing secret sits in a token the client can read. A token with no `sid` while storage is a resolver is REJECTED, not guessed at; tokens live an hour, so the client restarts from init. Static configs are unaffected ----------------------------- A static object emits NO `sid` (there is one destination to bind to) and skips every resolver path, so its tokens and behavior are byte-identical to before — pinned by two tests asserting the token has no `sid` and that the old flow still 200s. Also in this change ------------------- - keyStrategy ctx gains `metadata` and `req` (the issue's second ask). The existing { userId, fileName, contentType, size } fields are untouched, so existing strategies keep working. - FileMetadata gains an optional free-form `metadata` object, the wire field the client uses to say which class of upload this is. It is UNTRUSTED and documented as such in the type, the guide, and the API reference. - S3 clients are now cached per destination (endpoint|region|bucket| accessKeyId|forcePathStyle) instead of constructed per call, so multi-bucket traffic does not rebuild a connection pool on every presign. The secret key is never part of the cache key and is never logged. - Construct-time guards apply to static configs only; a resolver's result is validated per request (bucket/region present, S3-capable provider) and a bad one fails that request with 500 "Storage configuration error" through the Responder, with the real cause going to onError alone. There is deliberately no fallback bucket — a misrouted write is worse than a failed one. - /health reports checks.storage "skipped" and summary.storageType "dynamic" for a resolver: there is no single destination to probe, and calling an integrator's resolver from an unauthenticated liveness route would reach a real backend. - RENAME carried over from #338 in this same branch: PresignResponseContext's `metadata` is now `file` (it was always the FileMetadata), freeing `metadata` to mean the client's free-form hints consistently across PresignResponseContext, KeyStrategyContext and StorageResolverContext. One name, one meaning, per the repo's naming rule. - hooks.onPresignResponse's inline signature became the exported `OnPresignResponse` type so the `| void` idiom could carry a scoped, described eslint exemption rather than being degraded to `| undefined` (which would force every inspect-only hook to end in `return undefined`). New exported types (types only — the runtime public-API pin is unchanged): UpupStorageResolver, StorageResolverPhase, StorageResolverContext, UpupClientMetadata, OnPresignResponse. RED before the implementation (packages/server/tests/storage-routing.test.ts): FAIL tests/storage-routing.test.ts [ tests/storage-routing.test.ts ] Error: Cannot find module '/src/resolve-storage' imported from .../packages/server/tests/storage-routing.test.ts > 63| import { storageIdentity } from '../src/resolve-storage' Gates: server unit suite 300 passed | 35 skipped (handler-extended, response-contract and trust-model unmodified and green), typecheck (src + test tree) exit 0, eslint --max-warnings 0 clean, test:quality OK (374 test files, 0 exceptions), @upupjs/next typecheck exit 0 against the rebuilt server dist, docs:links:check / docs:snippets:check OK.
The previous commit made createS3Client memoize, and added `_resetS3ClientCacheForTests` with nothing calling it. Exercise the behavior instead of leaving a dead export: one client is reused for a repeated destination, bucket/region/endpoint each key separately, and a rotated accessKeyId yields a NEW client rather than one still signing with the old key. Gates: server typecheck exit 0, eslint --max-warnings 0 exit 0, test:quality exit 0 (374 test files), s3-client.test.ts 8 passed.
…two dev-box rules
Keeps both workstreams' behavior. The 2026-08 issue batch (#337-#344) and the cross-reload-resume feature both landed in packages/server's multipart lifecycle, so the reconciliation is semantic, not textual. Resolutions: - upload-routes.ts imports: union of both sides. - multipart/init token: carries BOTH #337's `sid` (destination binding) and the resume feature's `iat` (window anchor). - multipart/resume: the auto-merge left master's route resolving storage the pre-#337 way (`config.storage`, now a union with the resolver form) and dropping `sid` when re-issuing the token — one resume would have laundered a bucket-bound token into an unbound one. Now resolves via resolveBoundStorageOrFail(payload.sid) like the other three continuations and carries the binding forward. - StorageResolverPhase: gains 'multipart-resume'; resume is a storage-resolving route and the union may not omit it. - storage-routing.test.ts: pins the resume binding both ways (re-issued token keeps `sid`; a resume whose bound storage the resolver will not produce is 403 AUTH_DENIED). Proven RED before the fix. - multipart-resume.integration.test.ts: annotate storage as UpupStorageConfig, not UpupServerConfig['storage'] — since #337 that is a union with no `bucket`. - CLAUDE.md: rewrap one path list that prettier rejected (pre-existing on dev's tip; lint-staged checks *.md, which pnpm run prettier-check does not). - Versions take master's 3.2.0; dev's unreleased changeset survives. No trust-model assertion was loosened.
… package (#357) TypeScript 6 type-checks side-effect imports, so the documented `import '@upupjs/<framework>/styles'` failed to resolve declarations for a subpath exported as a bare string: src/index.ts(1,8): error TS2882: Cannot find module or type declarations for side-effect import of '@upupjs/react/styles'. Reproduced from real packed tarballs on typescript@6.0.3 and @7.0.2 under both moduleResolution bundler and node16, for all seven UI packages; typescript@5.9.3 is unaffected (it does not check side-effect imports). A production consumer on TS 6.0.3 had to hand-write an ambient `declare module` shim. Each framework package's ./styles subpath now resolves types through a generated empty-module declaration: "./styles": { "types": "./dist/styles.d.ts", "default": "./dist/tailwind-prefixed.css" } plus a typesVersions fallback, because moduleResolution "node10" ignores `exports` entirely and TS names that gap explicitly ("There are types at .../dist/styles.d.ts, but this result could not be resolved under your current 'moduleResolution' setting"). This is types-only. The `default` condition still points at the same unmoved dist/tailwind-prefixed.css, and Node's require.resolve / import.meta.resolve still land on the CSS for all seven packages, so bundlers and the preact/next copy-styles.mjs step are unaffected. The declaration is generated by scripts/emit-styles-dts.mjs, wired into each package's build:css so it is rebuilt with dist rather than hand-placed in it. Guarded by scripts/lib/styles-subpath.mjs (shape rules + negative cases in test:scripts) called from the package smoke consumer, so dropping the types condition, moving the CSS, or shipping a tarball without the declaration turns smoke:packages red even though the CSS itself would still be present.
fix: add a types condition to the ./styles subpath across all framework packages (#357)
`TokenEndpointCredentials.getPresignedUrl` threw
`Presign request failed: <status> <statusText>` without ever reading a non-ok
response, so the sentence a self-hosted token endpoint wrote for the user — a
plan-limit message, an expired-session notice — was discarded before any
handler saw it. With `onError` typed `(errorMessage: string) => void` the
thrown error's `.status` is not reachable either, which left consumers matching
the HTTP status back out of upup's own message text as the only way to recover
their own copy.
The strategy now reads the body and builds its error through
`uploadErrorFromResponse`, the helper direct-PUT, multipart, server credentials
and drive transfer already use: the body's message becomes `error.message`,
a `code` field lands on `error.code`, and `error.status` still carries the
status.
`parseErrorBody` selected its message with `error ?? msg`, so a body shaped
`{ message, error: true }` took the boolean, failed the string guard, and fell
through to the raw-JSON text fallback. It now prefers a *string* `error` and
otherwise keeps `message`.
Backward compatible by construction: same thrown class (`UpupNetworkError`
via `kind: 'network'`), and when the body is empty, whitespace or unreadable
the message stays byte-identical to the old wording. No public API change —
`onError` keeps its signature and no export surface moves.
RED before (vitest, packages/core):
FAIL tests/strategies/token-endpoint.test.ts > endpoint error body >
throws the endpoint's own message and code instead of the status line
Expected: "File exceeds your plan's 4608MB limit. Upgrade for larger uploads."
Received: "Presign request failed: 413 Payload Too Large"
FAIL ... > lifts a `message` field that sits beside a non-string `error` flag
FAIL ... > uses a plain-text error body verbatim
FAIL src/__tests__/errors.test.ts > parseErrorBody >
keeps `message` when a non-string `error` flag sits beside it
Test Files 2 failed (2)
Tests 4 failed | 59 passed (63)
GREEN after: 63 passed (63); full core suite 1699 passed (142 files);
react 644 passed, server 337 passed against a rebuilt core dist.
The three pre-existing error-path tests mock a response with no `text()` at
all and are left untouched, so they now double as the unreadable-body
compatibility pin.
…d images
Both steps re-encode through a canvas, and canvas has no animated encoder:
`drawImage` paints the first frame and `toBlob`/`convertToBlob` writes a still.
Enabling either option therefore replaced an uploaded animated GIF with a
frozen frame — the upload succeeded, nothing errored, and the user got a dead
image back. Both steps gated only on `file.type.startsWith('image/')`, so every
animated GIF was in scope; animated WebP and APNG were destroyed the same way
(`outputTypeFor` preserves those two types, so they came back as stills of the
same format).
`steps/animated-image.ts` sniffs the container and both steps now return the
file untouched when it is animated. The guard sits above the worker branch, so
one check covers the web-worker and main-thread encode paths, and the upload
itself is unaffected — an animated file simply skips these two steps.
Detection is byte-level rather than `ImageDecoder`-based, so it behaves the
same in every browser and is deterministic under test: GIF image descriptors
plus the NETSCAPE2.0/ANIMEXTS1.0 looping extension, the APNG `acTL` chunk
before the first `IDAT`, and the WebP `VP8X` animation flag or `ANIM`/`ANMF`
chunks. Everything else returns false without reading a byte past the MIME
check. Reading the blob is not a new cost class — the worker path already
calls `file.arrayBuffer()` and the main-thread path hands the file to
`createImageBitmap`, which decodes it to raw RGBA.
`thumbnailGenerator` is deliberately not guarded: a thumbnail is a still by
definition and is stored alongside the file rather than replacing it.
RED before (vitest, packages/core, guard removed, canvas runtime installed):
FAIL tests/steps/image-processing.test.ts >
animated images skip the canvas re-encode steps
expected File { size: 9, type: 'image/jpeg', name: 'loop.gif' }
to be File { size: 85, type: 'image/gif', name: 'loop.gif' }
Test Files 1 failed | 1 passed (2)
Tests 7 failed | 27 passed (34)
GREEN after: 34 passed (34); full core suite 1722 passed (143 files);
typecheck (src + test trees), eslint, prettier, test:quality and size-limit
all clean (core 403.25 kB against a 410 kB budget).
The compression guide's "When compression does nothing" list and the file-processing guide's stripExifData notes now cover the animation bypass, and the pipeline table's "any image/*" rows read "still image/*". Also qualifies the output-format line: only a *still* .gif turns into JPEG bytes under its original name. Calls out the consequence worth knowing — an animated image keeps its EXIF, because the step that would have stripped it is the same re-encode that would have flattened it.
…r-body fix(core): surface a custom uploadEndpoint's presign error body
fix(core): stop imageCompression and stripExifData flattening animated images
…eScript (#351 unblock) TypeScript >= 5.7 makes Uint8Array generic, so these fixtures typed as Uint8Array<ArrayBufferLike> and stopped satisfying BlobPart at their `new File([...])` / `new Blob([...])` call sites. Reproduced by floating typescript within its existing range (pnpm update -r typescript), which is what dependabot #351's lockfile regen resolves to (5.6.3 -> 5.9.3): packages/core tests/helpers/fixtures.ts(28,22): error TS2322: Type 'Uint8Array<ArrayBufferLike>' is not assignable to type 'BlobPart'. packages/core tests/helpers/fixtures.ts(64,22): error TS2322: ... same packages/core tests/helpers/node-canvas-shim.ts(72,26): error TS2322: ... same packages/storybook-config src/fixtures/heicSample.ts(10,20): error TS2322: ... same packages/storybook-config src/fixtures/pngSample.ts(10,20): error TS2322: ... same The storybook-config break also cascaded into the react/vue/vanilla/preact storybook apps, which typecheck that source directly. Both helpers now build the byte array with the single-argument Uint8Array constructor (a copy, typed Uint8Array<ArrayBuffer>) instead of the (buffer, byteOffset, length) view form, and base64ToBytes drops its explicit bare-Uint8Array return annotation so inference stays correct on either side of the lib change. No type argument is named anywhere, so this is green under the committed lockfile (TypeScript 5.6.3) and under the floated one (5.9.3). Verified: pnpm run typecheck 31/31 under both lockfiles; test 28/28; build; lint; prettier-check. base64.ts additionally re-formatted to the root 4-space prettier config (private-package src is outside the prettier-check glob, so only the pre-commit hook covers it).
…window (#351 unblock) packages/angular declared "typescript": "^5.6.0", so a lockfile regen floats it to 5.9.3 -- outside Angular 19's supported range. Reproduced by floating within the existing range (pnpm update -r typescript), the same resolution dependabot #351's lockfile lands on (its lockfile pins the packages/angular importer to typescript@5.9.3): @upupjs/angular build: ng-packagr / Ivy partial compilation "The Angular Compiler requires TypeScript >=5.5.0 and <5.9.0 but 5.9.3 was found instead." That build failure then cascaded into storybook-angular, whose stories cannot resolve the package that never got built: apps/storybook-angular src/stories/Uploader.stories.ts(5,39): error TS2307: Cannot find module '@upupjs/angular' or its corresponding type declarations. (same at Uploader.stories.ts:6, WorkerHeic.stories.ts:5 and :6) Narrowed to "~5.6.0", matching what apps/storybook-angular already pins, which holds the resolution at 5.6.x under a float. Angular 19's true ceiling is <5.9.0, so this can widen when the Angular major moves. The lockfile change is the single corresponding `specifier:` line in the packages/angular importer -- the resolved version is unchanged at 5.6.3, and `pnpm install --frozen-lockfile` passes. Verified with the float applied: pnpm run typecheck 31/31 green, including @upupjs/angular#build and @upupjs/storybook-angular#typecheck, with packages/angular held at TypeScript 5.6.3 while core and storybook-config floated to 5.9.3. Also green under the committed lockfile (typecheck 31/31, test 28/28, build, lint, prettier-check).
…idue fix: make Blob/BodyInit fixture types and the Angular TS pin survive a lockfile float (#351 unblock)
Review or Edit in CodeSandboxOpen the branch in Web Editor • VS Code • Insiders |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
devtomaster. Master is an ancestor of dev (reconciled in c7bf683), so this is a clean integration merge with no divergence.What ships
The 2026-08 issue batch (PR #353): per-request storage resolver with HMAC
siddestination binding,hooks.onPresignResponse, react error-surface re-export, unified prop-getter override contract, unhandled-rejection fix for input/drop/paste restriction failures,getDownloadUrl+downloadUrlExpiresIn, migration-guide warnings, pages-handlerRequestInit['body']compat, nightly tracking-issueGH_REPOfix.Master reconcile (PR merge c7bf683): the cross-reload-resume feature and the issue batch integrated; fixes two defects the auto-merge would have shipped — the re-issued resume token now carries
sidforward (a resume can no longer launder a bucket-bound token into an unbound one; RED-proven test coverage instorage-routing.test.ts), and/multipart/resumeresolves storage throughresolveBoundStorageOrFaillike every other continuation.StorageResolverPhasegains'multipart-resume'../stylesTS 6 fix (PR #363):typescondition + generateddist/styles.d.ts+typesVersionsin all 7 UI packages; kills theTS2882side-effect-import error on TS ≥ 6. Guarded by new smoke-tarball shape assertions.Presign error-body surfacing (PR #364): a custom
uploadEndpoint's non-ok response body now reachesonErroras a structuredUpupError(code + message) instead of being discarded.Animated-image protection (PR #365):
imageCompression/stripExifDatano longer flatten animated GIF/APNG/WebP to a single frame — dependency-free byte-sniffing in the lazy pipeline chunk; behavior change documented in the processing guides.#351 unblock (PR #368): fixture/type annotations made compatible with TypeScript ≥ 5.7's generic
Uint8Array, andpackages/angularpinstypescript: "~5.6.0"(Angular 19's compiler ceiling is < 5.9.0).Also: CI artifacts to self-hosted MinIO (#366), Firefox reload-resume docs, and the CI formatting blind-spot notes.
Release
Three unconsumed changesets ride this merge (issue batch minor + two patches); changesets will open the Version Packages PR against master, whose merge publishes the next minor for all nine
@upupjs/*packages via OIDC trusted publishing.Verification
Every constituent PR merged with full CI green (both rollup checks required); the reconcile merge additionally ran the full local gate suite and the trust-model integration tests against real MinIO. The
sidcarry-forward and storage binding are pinned by tests proven RED before the fix.Closes #337
Closes #338
Closes #339
Closes #341
Closes #342
Closes #343
Closes #344
Closes #357