Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

No compression in SSR mode #13

Closed
fvsadem opened this issue Sep 28, 2023 · 4 comments
Closed

No compression in SSR mode #13

fvsadem opened this issue Sep 28, 2023 · 4 comments
Assignees
Labels
documentation Improvements or additions to documentation

Comments

@fvsadem
Copy link

fvsadem commented Sep 28, 2023

Hi,

It seems that this is not working in SSR mode :(

My config files :

import { defineConfig } from "astro/config";
import node from "@astrojs/node";
import gzip from "astro-compressor";

// https://astro.build/config
export default defineConfig({
  integrations: [
    gzip({
      gzip: true,
      brotli: false,
      fileExtensions: [".html"]
    }),
  ],
  output: "server",
  adapter: node({
    mode: "standalone"
  })
});

I try with and without "fileExtensions" but this not working.

My astro file :

---
Astro.response.headers.set('Content-Encoding', 'gzip');
---

<!doctype html>
<html lang='fr'>
  <head>
    <title>Test</title>
    <meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
    <meta name='viewport' content='width=device-width, minimum-scale=1.0' />
    <meta name='description' content='Test' />
    <link
      rel='shortcut icon'
      href='assets/img/favicon/favicon.ico'
      type='image/x-icon'
    />
  </head>
  <body>
    Test
  </body>
</html>

The error :

image

Maybe it was not suppose to work like this...

Hope some help

Thanks

@sondr3
Copy link
Owner

sondr3 commented Sep 28, 2023

I hadn't considered SSR mode into this integration, I should probably add a note to the README that this will only work with static exports. If you inspect the dist folder when using a server and adapter you'll see there's no static exports generated so setting the header won't work. The correct way to solve it for SSR is to create middleware to handle requests. I threw together a small example, create a file called middleware.ts in src:

import { defineMiddleware } from "astro:middleware";
import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";

export const onRequest = defineMiddleware(async (context, next) => {
  const response = await next();
  const html = await response.text();

  const headers = response.headers;
  let res: string | Buffer;
  if (response.headers.get("Content-Type") === "text/html") {
    const reqEncodings =
      context.request.headers
        .get("accept-encoding")
        ?.split(",")
        .map((e) => e.trim()) ?? [];

    if (reqEncodings?.includes("br")) {
      headers.set("Content-Encoding", "br");
      res = brotliCompressSync(html);
    } else if (reqEncodings?.includes("gzip")) {
      headers.set("Content-Encoding", "gzip");
      res = gzipSync(html);
    } else if (reqEncodings?.includes("deflate")) {
      headers.set("Content-Encoding", "deflate");
      res = deflateSync(html);
    } else {
      res = html;
    }
  }

  return new Response(res, {
    status: response.status,
    headers: headers,
  });
});

Note that the above is terrible for performance at scale, this is just meant as an example. 😃

@sondr3 sondr3 added the documentation Improvements or additions to documentation label Sep 28, 2023
@sondr3 sondr3 self-assigned this Sep 28, 2023
@fvsadem
Copy link
Author

fvsadem commented Sep 29, 2023

@sondr3

Thanks for the reply and the snippet.

Yes adding a note about it is a good idea

Bye

@gunapalani

This comment was marked as off-topic.

@JLarky
Copy link

JLarky commented Aug 10, 2024

I tired to take a step forward to make it a bit more "production-ready", still a lot of things missing :) biggest issue I have is that I don't know how to make streams in node work :D the goal here was to make compression to cause as little latency as possible, which means I would have to figure out how to do http streaming 🫠

import { createBrotliCompress, deflateSync, gzipSync } from 'node:zlib';
import { pipeline, Writable } from 'stream';
import { defineMiddleware } from 'astro:middleware';

function skipStaticRoutes(url: URL) {
	// to fix this warning:
	// [WARN] `Astro.request.headers` is unavailable in "static" output mode, and in prerendered pages within "hybrid" and "server" output modes. If you need access to request headers, make sure that `output` is configured as either `"server"` or `output: "hybrid"` in your config file, and that the page accessing the headers is rendered on-demand.
	const path = url.pathname;
	return path === '/favicon.ico';
}

const cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/;
function shouldTransform(headers: Headers) {
	const cacheControl = headers.get('Cache-Control');
	// Don't compress for Cache-Control: no-transform
	// https://github.com/Alorel/shrink-ray/blob/9696b1c69dd7e255947e8ce2c83e9774b3762000/index.js#L354-L361
	return !cacheControl || !cacheControlNoTransformRegExp.test(cacheControl);
}

function getAcceptEncoding(headers: Headers) {
	const acceptEncoding = headers
		.get('accept-encoding')
		?.split(',')
		.map((e) => e.trim());
	if (!acceptEncoding) {
		return;
	}
	return acceptEncoding.includes('br')
		? 'br'
		: acceptEncoding.includes('gzip')
			? 'gzip'
			: acceptEncoding.includes('deflate')
				? 'deflate'
				: undefined;
}

function bodyToReadableStream(body: Body['body']) {
	const readableStream = body?.getReader();
	if (!readableStream) {
		return;
	}

	const bodyReadableStream = new ReadableStream({
		start(controller) {
			return pump();
			async function pump(): Promise<void> {
				return readableStream?.read().then(({ done, value }) => {
					// When no more data needs to be consumed, close the stream
					if (done) {
						controller.close();
						return;
					}
					// Enqueue the next data chunk into our target stream
					controller.enqueue(value);
					// console.log('body read', value.length);
					return pump();
				});
			}
		},
	});
	return bodyReadableStream;
}

function createStreams() {
	let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
	const readStream = new ReadableStream({
		start(ctrl) {
			controller = ctrl;
		},
	});

	const writeStream = new Writable({
		write(chunk: Buffer, _encoding, callback) {
			// console.log('writeStream', chunk.length);
			try {
				controller?.enqueue(chunk);
			} catch (e) {
				console.error('writeStream error:', e);
			}
			callback();
		},
		final(callback) {
			controller?.close();
			callback();
		},
	});
	return { readStream, writeStream };
}

export const compress = defineMiddleware(async (context, next) => {
	if (skipStaticRoutes(context.url)) {
		return next();
	}

	const reqEncoding = getAcceptEncoding(context.request.headers);
	if (reqEncoding === undefined) {
		return next(); // Skip compression if none was requested
	}

	if (!shouldTransform(context.request.headers)) {
		return next();
	}

	const response = await next();
	// TODO: look into npm:compressible
	if (response.headers.get('Content-Type') !== 'text/html') {
		return next(); // Skip compression if the response is not HTML
	}

	// TODO: skip compression if content-length is too small
	// TODO: skip HEAD requests

	const headers = response.headers;

	headers.set('Vary', 'Accept-Encoding');
	headers.delete('Content-Length');
	headers.set('Content-Encoding', reqEncoding);

	if (reqEncoding === 'br') {
		// convert request body to readable stream
		const bodyReadableStream = bodyToReadableStream(response.body);
		if (!bodyReadableStream) {
			return next();
		}

		const compression = createBrotliCompress({});

		const { readStream, writeStream } = createStreams();

		const timer = setInterval(() => {
			compression.flush();
		}, 100);
		compression.on('finish', () => {
			clearInterval(timer);
		});

		pipeline(bodyReadableStream, compression, writeStream, (err) => {
			if (err) {
				console.error('Brotli compression failed:', err);
			}
		});

		return new Response(readStream, {
			status: response.status,
			headers: headers,
		});
	}

	console.time('compress');

	const sendResponse = (res: Buffer) =>
		new Response(res, {
			status: response.status,
			headers: headers,
		});

	// fallback to sync version of compression
	// https://github.com/sondr3/astro-compressor/issues/13#issuecomment-1739721634
	const html = await response.text();

	console.timeEnd('compress');

	if (reqEncoding === 'gzip') {
		return sendResponse(gzipSync(html));
	} else if (reqEncoding === 'deflate') {
		return sendResponse(deflateSync(html));
	}
	return reqEncoding satisfies never;
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
documentation Improvements or additions to documentation
Projects
None yet
Development

No branches or pull requests

4 participants