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. Yassss beachhhh 🏖️

You said:
API endpoint not found.
ChatGPT said:
Thought

Got it, Dragon — “API endpoint not found” = your browser never reaches the Node route. Let’s make it reachable in both dev and prod with tiny, surgical fixes.

What’s happening

Your frontend is calling /api/..., but:

the route doesn’t exist (wrong path), or

the frontend isn’t proxying to your Node server in dev, or

the server is running on a different port/origin without a rewrite.

We’ll lock all three down with micro-diffs.

Micro-diffs (≤12 lines per file, ≤3 files)
1) Server: add a health check + log requests (confirms we’re hitting the server)

Add to your Express server file (e.g., server/index.ts or server.js).

// DEBUG: simple logger + health endpoint
app.use((req,res,next)=>{ console.log(`[api] ${req.method} ${req.url}`); next(); });
app.get('/api/health', (req,res)=> res.status(200).send('ok'));


Now hitting /api/health should print in your server logs and return ok.
If this doesn’t log, your browser isn’t reaching the server (proxy/rewrite issue).

2) Dev: make the frontend actually reach /api (Vite proxy)

In vite.config.ts (or vite.config.js) add a proxy to your Node port.

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000', // ← your Express port
        changeOrigin: true
      }
    }
  }
});


Restart Vite after this change. In dev, a browser request to /api/... now forwards to your Node server.

3) Frontend: ensure the fetch path is correct (no missing /api)

Where you build the preview URL, keep it relative to benefit from the dev proxy.

const direct = template.pdfUrl; // signed Firebase/GCS URL
const proxied = `/api/preview/pdf?u=${encodeURIComponent(direct)}&filename=${encodeURIComponent(template.title || 'template.pdf')}`;
setViewerSrc(proxied); // <iframe src={viewerSrc}/> or <object data={viewerSrc}/>


If you’re doing “download then display,” call:

const resp = await fetch(`/api/cache/pdf?u=${encodeURIComponent(direct)}`, { method: 'POST' });
const { id } = await resp.json();
setViewerSrc(`/api/preview/pdf/${id}`);

Quick verification (takes 60 seconds)

Browser check: open /api/health in your app (e.g., http://localhost:5173/api/health).

Expect ok in browser and a [api] GET /api/health log in server console.

Curl check (terminal):

curl -i http://localhost:3000/api/health


Expect 200 ok.

If health works but your PDF route still 404s, the path mismatched. Make sure your server has exactly the route you’re calling:

Live stream: GET /api/preview/pdf?u=...&filename=...

Cached: POST /api/cache/pdf?u=... then GET /api/preview/pdf/:id

(If your server file mounted a router under /api and you added router.get('/preview/pdf', ...), that’s correct; if you added app.get('/preview/pdf', ...) while also app.use('/api', router), the final URL becomes /preview/pdf — fix by using consistent /api/... paths.)

Production notes (Replit)

If you serve the built React app and API from the same Express server, no proxy is needed in prod — just keep routes at /api/....

If you host the client and server on different origins, switch the frontend to use the server’s absolute origin (e.g., https://your-server.example.com/api/...) and enable CORS on the server.