withastro / astro

The web framework for content-driven websites. ⭐️ Star to support our work!
https://astro.build
Other
45.48k stars 2.38k forks source link

SSR Compression missing? Document size of SSR Astro + SPA much larger in comparison to SPA without Astro #9397

Closed welschmoor closed 8 months ago

welschmoor commented 9 months ago

Astro Info

Astro                    v3.6.1
Node                     v20.5.0
System                   macOS (arm64)
Package Manager          yarn
Output                   server
Adapter                  @astrojs/node
Integrations             @astrojs/tailwind
                         @astrojs/solid-js
                         @astrojs/svelte
                         astro-compress

If this issue only occurs in one browser, which browser is a problem?

Chrome, Firefox

Describe the Bug

If I use Astro + Svelte, and create a large test component with a 1000 div elements, the document size gets large, like 50kb over the network. If I use the exact same code inside SvelteKit (no Astro), then the Document size is 20 times smaller.

I assume the compression is missing in SSR mode (astrojs/node), which makes it unusable, since a typical large home page will weigh half a megabyte.

Astro+Svelte

Screenshot 2023-12-06 at 23 30 21

Astro+Solid

Screenshot 2023-12-06 at 23 17 21

SvelteKit, the same App:

Screenshot 2023-12-06 at 23 17 02

Astro3 and Astro4 behave the same way, and it's also independent of what framework, Solid, Preact or Svelte we're using.

I assume, but did not test, that only SSR is affected. Did not test if SSG is affected.

What's the expected result?

HTML Document should be compressed.

Link to Minimal Reproducible Example

No

Participation

lilnasy commented 9 months ago

I am not sure built-in compression is necessary. Node servers typically run behind reverse proxies which are able to do it more efficiently. It would be a duplicated effort, and if you want to add it, the middleware is really simple anyway:

import { defineMiddleware } from "astro:middleware"

export const onRequest = defineMiddleware(async (_context, next) => {
    const response = await next()
    return new Response(response.body?.pipeThrough(new CompressionStream("gzip")), {
        ...response,
        headers: {
            ...response.headers,
            "Content-Encoding": "gzip"
        }
    })
})

SvelteKit's node adapter did compress SSR responses at one point but it was removed over a year ago for similar reasons. Can you share what version you are using?

Precompressing static resources would be good enhancement but the library we are using (send) does not support it and generally seems "done" on the maintenance front. It might be a while to introduce precompression, because it would likely mean migrating to something else for static files.

welschmoor commented 9 months ago

SvelteKit's node adapter did compress SSR responses at one point but it was removed over a year ago for similar reasons. Can you share what version you are using?

I created two SvelteKit apps about a week ago using the standard command from the Docs . One with opting for Svelte5 beta, another Svelte4.

I have just checked and both SvelteKit apps compress the document, at least judging by its size: Svelte5 3600 lines, 122k characters, 10.9KB doc Svelte4 2000 lines, 86k characters, 2.6KB doc (please disregard the difference between them, they don't render the same amount of stuff). Also I see new data on each page refresh in these apps that is coming from a server, so I assume I am using SSR. Also judging by req/s, it is SSR. If I export const prerender = true in Svelte, then the req/s go up significantly, but the data is always the same, which also speaks for me using SSR.

As for your suggested middleware: It works! astro3 + svelte 1800 lines 115k characters, is now 1.3KB instead of 86KB, the SSR req/s fell down from 980 to 810.

It's good to know that nginx or caddy can do the same. Most of us don't know that... It would be nice if we could update the docs saying "By default Astro SSR does not compress the HTML Document sent to the browser. We recommend using your reverse proxy for compression, as it's more efficient. But if you insist, compressing the html document file can be done using astro middleware like so: "insert the code snippet from above" "

matthewp commented 8 months ago

Hey! As @lilnasy pointed out, you can do compression as middleware if you'd like. It's not something we currently want to add to Astro as it's commonly down already via the CDN or proxy server layer, and those layers are able to do it more efficiently. Astro doing it would be double work in most cases. Thanks for filing though!

IlirEdis commented 5 months ago

Its funny that the middleware posted above increases my document size by about 20kb more. Any guide how to setup compression with Vercel?

jazoom commented 1 month ago

I am not sure built-in compression is necessary. Node servers typically run behind reverse proxies which are able to do it more efficiently. It would be a duplicated effort, and if you want to add it, the middleware is really simple anyway:

import { defineMiddleware } from "astro:middleware"

export const onRequest = defineMiddleware(async (_context, next) => {
    const response = await next()
    return new Response(response.body?.pipeThrough(new CompressionStream("gzip")), {
        ...response,
        headers: {
            ...response.headers,
            "Content-Encoding": "gzip"
        }
    })
})

That didn't work for me. The below does, however.

const compression = defineMiddleware(async ({ cookies, locals }, next) => {
    const response = await next();

    if (!response.body) {
        return response;
    }

    const contentType = response.headers.get("Content-Type");
    const isCompressible =
        contentType &&
        (contentType.includes("text/") ||
            contentType.includes("application/json") ||
            contentType.includes("application/javascript") ||
            contentType.includes("application/xml"));

    if (!isCompressible) {
        return response;
    }

    const compressedBody = response.body.pipeThrough(
        new CompressionStream("gzip"),
    );
    const newHeaders = new Headers(response.headers);
    newHeaders.set("Content-Encoding", "gzip");

    return new Response(compressedBody, {
        status: response.status,
        statusText: response.statusText,
        headers: newHeaders,
    });
});

Bun doesn't yet support CompressionStream so I also needed to use this: https://github.com/oven-sh/bun/issues/1723#issuecomment-1774174194

For the record, I disagree with Astro not supporting compression out of the box. For simple setups IMHO it's ridiculous to add an entirely new process in between your server and the client, especially if you're already putting Cloudflare in front. Node/Deno/Bun have no trouble compressing responses with good enough performance for 99% of web servers.

The "duplicated effort" is the effort everyone must go through with Astro to add their own compression middleware.

jazoom commented 1 month ago

To make things even easier for people in future, here's the full implementation, updated to also support brotli compression.

// middleware.ts

import { defineMiddleware, sequence } from "astro:middleware";
import "@lib/compressionStream.js"; // polyfill CompressionStream - // remove this once Bun supports CompressionStream: https://github.com/oven-sh/bun/issues/1723

const compression = defineMiddleware(
    async ({ request, cookies, locals }, next) => {
        const response = await next();

        if (!response.body) {
            return response;
        }

        const contentType = response.headers.get("Content-Type");
        const isCompressible =
            contentType &&
            (contentType.includes("text/") ||
                contentType.includes("application/json") ||
                contentType.includes("application/javascript") ||
                contentType.includes("application/xml"));

        if (!isCompressible) {
            return response;
        }

        const acceptEncoding = request.headers.get("Accept-Encoding") || "";
        let compressionType = "";

        const acceptedEncodings = acceptEncoding
            .split(",")
            .map((encoding) => encoding.trim().toLowerCase());

        if (acceptedEncodings.includes("br")) {
            compressionType = "br";
        } else if (acceptedEncodings.includes("gzip")) {
            compressionType = "gzip";
        } else if (acceptedEncodings.includes("deflate")) {
            compressionType = "deflate";
        }

        if (!compressionType) {
            return response;
        }

        const compressedBody = response.body.pipeThrough(
            new CompressionStream(compressionType),
        );
        const newHeaders = new Headers(response.headers);
        newHeaders.set("Content-Encoding", compressionType);

        return new Response(compressedBody, {
            status: response.status,
            statusText: response.statusText,
            headers: newHeaders,
        });
    },
);

export const onRequest = sequence(compression);

// compressionStream.js

// @bun
// This module is only required for Bun, because it doesn't currently have CompressionStream. This module just polyfills it in.
// Use like this: import "./compressionStream.js";
// It should be possible to remove this soon and depend upon the built-in CompressionStream once it has landed in Bun.
// Modified from: https://github.com/oven-sh/bun/issues/1723#issuecomment-1774174194

import zlib from "node:zlib";

const make = (ctx, handle) =>
    Object.assign(ctx, {
        writable: new WritableStream({
            write: (chunk) => handle.write(chunk),
            close: () => handle.end(),
        }),
        readable: new ReadableStream({
            type: "bytes",
            start(ctrl) {
                handle.on("data", (chunk) => ctrl.enqueue(chunk));
                handle.once("end", () => ctrl.close());
            },
        }),
    });

globalThis.CompressionStream ??= class CompressionStream {
    constructor(format) {
        make(
            this,
            format === "deflate"
                ? zlib.createDeflate()
                : format === "gzip"
                    ? zlib.createGzip()
                    : format === "br"
                        ? zlib.createBrotliCompress()
                        : zlib.createDeflateRaw(),
        );
    }
};

globalThis.DecompressionStream ??= class DecompressionStream {
    constructor(format) {
        make(
            this,
            format === "deflate"
                ? zlib.createInflate()
                : format === "gzip"
                    ? zlib.createGunzip()
                    : format === "br"
                        ? zlib.createBrotliDecompress()
                        : zlib.createInflateRaw(),
        );
    }
};