Chrome’s PDF viewer requires proper HTTP Range behavior. Here are two drop-in fixes so your proxy renders perfectly:

A) Live proxy (forward Range to Firebase) — simplest
// GET /api/preview/pdf?u=<encoded GCS/Firebase URL>&filename=optional.pdf
app.get('/api/preview/pdf', async (req, res) => {
  try {
    const u = req.query.u; if (!u) return res.status(400).send('Missing ?u');
    const url = decodeURIComponent(String(u));
    const allowed = /^(https:\/\/(?:storage\.googleapis\.com|firebasestorage\.googleapis\.com|[^/]+\.firebasestorage\.app)\/)/i;
    if (!allowed.test(url)) return res.status(400).send('Blocked');

    const headers = {};
    if (req.headers.range) headers.Range = req.headers.range;   // ← forward Range
    if (req.headers['if-none-match']) headers['If-None-Match'] = req.headers['if-none-match'];
    if (req.headers['if-modified-since']) headers['If-Modified-Since'] = req.headers['if-modified-since'];

    const upstream = await fetch(url, { headers, redirect: 'follow' });

    // Mirror status (206/200/304/etc.)
    res.status(upstream.status);

    // Mirror relevant headers so the viewer can seek
    for (const h of [
      'content-type','content-length','accept-ranges','content-range',
      'etag','last-modified','cache-control','vary','date'
    ]) {
      const v = upstream.headers.get(h);
      if (v) res.setHeader(h, v);
    }

    // Ensure inline
    const filename = (req.query.filename || 'document.pdf').toString();
    if (!res.getHeader('content-disposition')) {
      res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
    }
    if (!res.getHeader('content-type')) res.setHeader('Content-Type','application/pdf');

    if (upstream.status === 304) return res.end();
    if (!upstream.body) return res.end();

    const { Readable } = await import('node:stream');
    Readable.fromWeb(upstream.body).pipe(res);
  } catch (e) {
    console.error('PDF proxy error', e);
    res.status(502).send('Proxy error');
  }
});

B) “Download then display” (cached file) — full Range support

If you cache to disk (or your bucket), serve with proper byte slicing:

import { createReadStream, createWriteStream } from 'node:fs';
import { stat, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
const CACHE_DIR = '.cache/pdfs'; await mkdir(CACHE_DIR, { recursive: true });

// POST /api/cache/pdf?u=...
app.post('/api/cache/pdf', async (req, res) => {
  const u = req.query.u; if (!u) return res.status(400).send('Missing ?u');
  const url = decodeURIComponent(String(u));
  const allowed = /^(https:\/\/(?:storage\.googleapis\.com|firebasestorage\.googleapis\.com|[^/]+\.firebasestorage\.app)\/)/i;
  if (!allowed.test(url)) return res.status(400).send('Blocked');
  const id = createHash('sha1').update(url).digest('hex');
  const path = `${CACHE_DIR}/${id}.pdf`;
  try { await stat(path); return res.json({ id }); } catch {}

  const r = await fetch(url);
  if (!r.ok || !r.body) return res.status(502).send('Fetch failed');
  await new Promise((resolve, reject) => {
    // @ts-ignore
    require('node:stream').Readable.fromWeb(r.body)
      .pipe(createWriteStream(path).on('finish', resolve).on('error', reject));
  });
  res.json({ id });
});

// GET /api/preview/pdf/:id  (serves with Range)
app.get('/api/preview/pdf/:id', async (req, res) => {
  try {
    const id = req.params.id;
    const path = `${CACHE_DIR}/${id}.pdf`;
    const st = await stat(path);
    const size = st.size;

    res.setHeader('Accept-Ranges', 'bytes');
    res.setHeader('Content-Type', 'application/pdf');
    res.setHeader('Content-Disposition', `inline; filename="${id}.pdf"`);

    const range = req.headers.range;
    if (!range) {
      res.setHeader('Content-Length', size);
      return createReadStream(path).pipe(res);
    }

    // Parse "bytes=start-end"
    const m = /bytes=(\d+)-(\d+)?/.exec(range);
    if (!m) {
      res.status(416).setHeader('Content-Range', `bytes */${size}`);
      return res.end();
    }
    const start = Number(m[1]);
    const end = m[2] ? Number(m[2]) : size - 1;
    if (start >= size || end >= size || start > end) {
      res.status(416).setHeader('Content-Range', `bytes */${size}`);
      return res.end();
    }

    res.status(206);
    res.setHeader('Content-Range', `bytes ${start}-${end}/${size}`);
    res.setHeader('Content-Length', end - start + 1);
    createReadStream(path, { start, end }).pipe(res);
  } catch (e) {
    res.status(404).send('Not cached');
  }
});

Frontend usage
// Live proxy
const proxied = `/api/preview/pdf?u=${encodeURIComponent(template.pdfUrl)}&filename=${encodeURIComponent(template.title||'template.pdf')}`;

// OR cache-first
const { id } = await (await fetch(`/api/cache/pdf?u=${encodeURIComponent(template.pdfUrl)}`, { method: 'POST' })).json();
const proxied = `/api/preview/pdf/${id}`;

// Use `proxied` in <iframe src=...> or <object data=...>

Quick tests
# Expect 200 OK with Accept-Ranges: bytes
curl -I "http://localhost:3000/api/preview/pdf?u=<ENCODED_URL>"

# Expect 206 Partial Content and Content-Range
curl -H "Range: bytes=0-1023" -I "http://localhost:3000/api/preview/pdf?u=<ENCODED_URL>"


If those two headers show up (Accept-Ranges and Content-Range with 206), your modal will stop being blank and the PDF will render.