@versatiles/google-cloud
Version:
A server for VersaTiles in Google Cloud Run
153 lines (152 loc) • 6.19 kB
JavaScript
import { ENCODINGS } from './encoding.js';
import { Writable, Readable } from 'stream';
import { pipeline } from 'stream/promises';
const maxBufferSize = 10 * 1024 * 1024; // Define the maximum buffer size for streaming
/**
* A writable stream that buffers data up to a maximum size, then switches to stream mode.
* It is used in the recompression process to handle data efficiently.
*/
export class BufferStream extends Writable {
// Private members and constructor
#responder;
#buffers = [];
#size = 0;
#bufferMode = true;
/*
* Class constructor will receive the injections as parameters.
*/
constructor(responder) {
super();
this.#responder = responder;
}
/**
* Handles writing of chunks to the stream.
* @param chunk - The data chunk to write.
* @param encoding - The encoding of the chunk.
* @param callback - Callback to signal completion or error.
*/
_write(chunk, encoding, callback) {
// Buffer the chunks until the maximum buffer size is reached
if (this.#bufferMode) {
this.#buffers.push(chunk);
this.#size += chunk.length;
// Switch to stream mode if max buffer size is exceeded
if (this.#size >= maxBufferSize) {
this.#responder.log(`bufferstream - stop bufferMode: ${this.#buffers.length}`);
this.#bufferMode = false;
this.#prepareStreamMode();
this.#responder.sendHeaders(200);
const buffer = Buffer.concat(this.#buffers);
this.#buffers.length = 0;
// Write the buffer to the responder stream
this.#responder.write(buffer, callback);
}
else {
callback();
}
}
else {
// Write directly to the responder stream in stream mode
this.#responder.write(chunk, callback);
}
}
/**
* Finalizes the stream, ensuring all buffered data is written.
* @param callback - Callback to signal completion or error.
*/
_final(callback) {
// Log finishing the stream if log prefix is provided
this.#responder.log('bufferstream - finish stream');
// Handle the finalization of the buffer mode
if (this.#bufferMode) {
const buffer = Buffer.concat(this.#buffers);
this.#responder.log(`bufferstream - flush to handleBuffer: ${buffer.length}`);
this.#prepareBufferMode(buffer.length);
this.#responder.sendHeaders(200);
this.#responder.end(buffer, callback);
}
else {
// End the responder stream in stream mode
this.#responder.end(callback);
}
}
// Prepare the response headers for buffer mode, setting content-length and removing transfer-encoding
#prepareBufferMode(bufferLength) {
const { headers } = this.#responder;
headers.remove('transfer-encoding');
headers.set('content-length', String(bufferLength));
this.#responder.log(`bufferstream - response header for buffer: ${headers.toString()}`);
this.#responder.log(`bufferstream - response buffer length: ${bufferLength}`);
}
// Prepare the response headers for stream mode, setting transfer-encoding to chunked and removing content-length
#prepareStreamMode() {
const { headers } = this.#responder;
headers.set('transfer-encoding', 'chunked');
headers.remove('content-length');
this.#responder.log(`bufferstream - response header for stream: ${headers.toString()}`);
}
}
/**
* Recompresses a given body (Buffer or Readable stream) using the best available encoding.
* @param responder - The ResponderInterface instance handling the response.
* @param body - The body to recompress, either as a Buffer or a Readable stream.
* @param logPrefix - Optional prefix for logging purposes.
* @returns A promise that resolves when recompression is complete.
*/
export async function recompress(responder, body) {
// Detect and set the incoming and outgoing encodings
const encodingIn = responder.headers.getContentEncoding();
let encodingOut = encodingIn;
// do not recompress images, videos, ...
switch (responder.headers.getMediaType()) {
case 'audio':
case 'image':
case 'video':
if (!responder.acceptEncoding(encodingOut)) {
// decompress it
encodingOut = ENCODINGS.raw;
}
break;
default:
if (responder.fastRecompression) {
if (!responder.acceptEncoding(encodingOut)) {
// decompress it
encodingOut = ENCODINGS.raw;
}
}
else {
// find best accepted encoding
encodingOut = responder.findBestEncoding();
}
}
// Set vary header for proper handling of different encodings by clients
responder.headers.set('vary', 'accept-encoding');
// Set the appropriate encoding header based on the selected encoding
encodingOut.setEncodingHeader(responder.headers);
// Prepare the streams for the pipeline
const streams = [];
if (Buffer.isBuffer(body)) {
streams.push(Readable.from(body));
}
else if (Readable.isReadable(body)) {
streams.push(body);
}
else {
throw Error('neither Readable nor Buffer');
}
// Handle recompression if the input and output encodings are different
if (encodingIn !== encodingOut) {
responder.log(`recompress: ${encodingIn.name} to ${encodingOut.name}`);
if (encodingIn.decompressStream) {
streams.push(encodingIn.decompressStream());
}
if (encodingOut.compressStream) {
streams.push(encodingOut.compressStream(responder.fastRecompression));
}
responder.headers.remove('content-length');
}
// Add the BufferStream to the pipeline and execute the pipeline
streams.push(new BufferStream(responder));
await pipeline(streams);
return;
}