Skip to content

[Bug]: Compressed asset responses lose their Content-Type, so HTML previews render as plain text #10935

Description

@stefanofa

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Run the server from main at or after bd56e92 (the Effect rc.112 upgrade, chore(deps): upgrade Effect to rc.112 and Alchemy to beta.76 #10652). Reproduced against a dev server from 7220dfe; the relevant code is unchanged on main as of 6c58362.
  2. Get a signed asset URL for an .html workspace file larger than 1 KiB (assets.createUrl with a workspace-file resource, which is what opening an HTML file in the file viewer does) and request it the way a browser does:
    curl -sD - -o /dev/null -H 'Accept-Encoding: gzip, deflate, br, zstd' 'http://127.0.0.1:<port>/api/assets/<token>/page.html'
    
  3. Request the same URL without Accept-Encoding.

Expected behavior

Both responses carry content-type: text/html; charset=utf-8 and the sandbox CSP that assetResponseHeaders sets, and the browser renders the page.

Actual behavior

Step 2 answers 200 with content-encoding: br, x-content-type-options: nosniff and the CSP header, and no content-type at all. Step 3 answers with content-type: text/html; charset=utf-8. Because of nosniff, Chromium shows the HTML source as plain text, and a .css file served the same way is refused as a stylesheet. Images are unaffected (the middleware does not compress them) and so are bodies under 1 KiB.

The tracer records headers before the compression handler runs, so http.server GET spans still show text/html for a response that reached the client without a type.

Impact

Major degradation: every HTML, CSS, JS and PDF file the asset route serves to a browser that accepts compression, which is every browser.

Cause

httpCompressionLayer in apps/server/src/http.ts is Effect's HttpMiddleware.compression(). In effect 4.0.0-rc.112 the Node implementation rebuilds a file response around a body that carries no content type, and HttpServerResponse.setBody then strips the header. HttpServerResponse.text(..., { contentType }) survives because its body carries the type; HttpServerResponse.file puts the type in the headers only. Details, a minimal reproduction and a suggested fix are in the Effect issue: Effect-TS/effect#8146

Workaround

Append no-transform to the asset Cache-Control value in assetResponseHeaders. The middleware honours the directive and asset responses go out uncompressed with their type. Two expectations in http.test.ts pin the exact header value and need the same change.

Activity

  1. juliusmarminge commented on Sep 9, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed. This is a real regression on main after the Effect 4.0.0-rc.112 upgrade (#10652). Root cause is upstream: Effect-TS/effect#8146.

    httpCompressionLayer in apps/server/src/http.ts is Effect's HttpMiddleware.compression(), applied globally. Workspace HTML/CSS/JS assets go through assetFileResponse → HttpServerResponse.file. On Node, that puts Content-Type in headers only; the Raw body has no type. Compression rebuilds the body without one, and HttpServerResponse.setBody then strips the header. The client gets content-encoding: br, x-content-type-options: nosniff, CSP — and no content-type. Chromium therefore shows HTML as plain text and refuses CSS as a stylesheet.

    That matches the report: uncompressed requests keep text/html; charset=utf-8; bodies under 1 KiB and non-compressible types (raster images) are skipped. Traces still show text/html because they record headers before compression.

    Scope note: Effect's default compressible list includes text/*, application/javascript, and image/svg+xml, but not application/pdf. PDF previews should still send a type. Large SVGs can hit the same missing-type bug. Static app files are fine — they use HttpServerResponse.stream with contentType on the body.

    workspace-file grants are directory-scoped, so sibling CSS/JS loaded by an HTML preview go through the same compressed file path.

    Next step

    Apply the local workaround now: append no-transform to the asset Cache-Control value in assetResponseHeaders (private, max-age=3600, no-transform; video override private, no-store, no-transform). The middleware honours that directive and leaves the type in place. Update the two exact Cache-Control expectations in apps/server/src/http.test.ts.

    Track Effect#8146 as the real fix (carry response.headers["content-type"] ?? body.contentType into the compressed body). A local Effect patch is optional; the header change is enough to restore previews.

    Related: #9143, #6409, #5916.

  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 9, 2026
  3. jadeva commented on Sep 11, 2026

    @jadeva
    Contributor

    Another visible symptom of this: a custom project icon that points at an SVG larger than about 24 KB never shows. The client inlines icons up to 32 KB as data URLs, so a larger SVG loads from the signed asset URL instead. It arrives with content-encoding: br and no content-type, nosniff makes the <img> fail, and the project falls back to its automatic icon. PNG and ICO icons still work because the browser sniffs them.

    Reproduced on T3 Code Desktop 0.0.41-nightly.20260910 on Windows with a 56 KB and a 78 KB SVG. Setting the content type on the response body in assetFileResponse made the icon render in a desktop dev build, so the no-transform workaround in #10948 should cover this too.

  4. stefanofa commented on Sep 11, 2026

    @stefanofa
    Author

    Should have been solved by effect: Effect-TS/effect#8148

  5. josephv123 commented on Sep 29, 2026

    @josephv123

    This appears to be fixed on main, though it's still in the latest stable release.

    Minimal repro without T3. It serves HttpServerResponse.file(path, { headers: { "Content-Type": "text/html; charset=utf-8" } }) through HttpMiddleware.compression(), with exact-pinned effect, @effect/platform-node and @effect/platform-node-shared:

    rc.112  accept-encoding="gzip, deflate, br, zstd" -> content-type=undefined                  content-encoding=br
    rc.112  accept-encoding=""                        -> content-type=text/html; charset=utf-8   content-encoding=undefined
    rc.115  accept-encoding="gzip, deflate, br, zstd" -> content-type=text/html; charset=utf-8   content-encoding=br
    rc.115  accept-encoding=""                        -> content-type=text/html; charset=utf-8   content-encoding=undefined
    

    So this could probably be closed once v0.0.43 ships. The no-transform workaround PRs (#10948, #11768) look unnecessary now.

  6. juliusmarminge commented on Oct 2, 2026

    @juliusmarminge
    Member

    Thanks for taking the time to report this and provide the details. We revisited it during the orchestrator V2 cleanup.

    Pinned main uses Effect/platform-node rc.115, and the vendored compression implementation preserves response header content-type for Raw and Stream bodies. This matches the latest reporter comparison identifying rc.115 as fixed; the older stable release remains affected.

    I’m closing this based on the current source and the evidence in this thread.

    Source reviewed.

    Resolved on audited main, not a claim that stable 0.0.42 contains the fix.

    If you still hit this on a current build, please reply with the app/server versions and the steps that reproduce it. We can reopen this if the original problem is still there.

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

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.upstreamvia-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions