Skip to content
File

Blob: worker/dev/live-backend.ts

typescript76 lines
1import https from 'node:https';
2import type { Plugin } from 'vite';
3import { allowedProxyRequest, developmentHosts } from './hosts.ts';
4 
5/** Opt-in local viewer API proxy. Never included in the deployed Worker. */
6export function liveBackend(origin?: string, allowedHosts = developmentHosts()): Plugin {
7 const target = origin ? new URL(origin) : undefined;
8 if (
9 target &&
10 (target.protocol !== 'https:' ||
11 target.username ||
12 target.password ||
13 target.pathname !== '/' ||
14 target.search ||
15 target.hash)
16 ) {
17 throw new Error('RADIO_API_ORIGIN must be an HTTPS origin without credentials or a path.');
18 }
19 return {
20 name: 'radio-live-backend',
21 apply: 'serve',
22 configureServer(server) {
23 if (!target) return;
24 server.middlewares.use((request, response, next) => {
25 if (!/^\/api\/(?:login|status|viewers)(?:[/?]|$)/.test(request.url ?? '')) return next();
26 // This middleware runs before Vite's own host check. Validate both host
27 // and browser origin before rewriting the upstream origin.
28 const host = request.headers.host ?? '';
29 if (
30 !allowedProxyRequest(
31 host,
32 request.headers.origin,
33 request.headers['sec-fetch-site'],
34 allowedHosts,
35 )
36 ) {
37 response.writeHead(403).end('Origin not allowed.');
38 return;
39 }
40 const headers = { ...request.headers, host: target.host, origin: target.origin };
41 delete headers.connection;
42 const upstream = https.request(
43 new URL(request.url!, target),
44 {
45 method: request.method,
46 headers,
47 timeout: 26000,
48 },
49 (result) => {
50 const resultHeaders = { ...result.headers };
51 // Production cookies stay HttpOnly and SameSite=Strict. Only this
52 // explicitly selected HTTP development endpoint needs Secure removed.
53 if (resultHeaders['set-cookie'])
54 resultHeaders['set-cookie'] = resultHeaders['set-cookie'].map((cookie) =>
55 cookie.replace(/;\s*Secure\b/gi, ''),
56 );
57 response.writeHead(result.statusCode ?? 502, resultHeaders);
58 result.pipe(response);
59 },
60 );
61 upstream.on('timeout', () => upstream.destroy(new Error('Upstream timeout')));
62 upstream.on('error', () => {
63 if (!response.headersSent)
64 response.writeHead(502, {
65 'Content-Type': 'application/json',
66 'Cache-Control': 'no-store',
67 });
68 response.end(JSON.stringify({ error: 'The live radio API is unavailable.' }));
69 });
70 request.on('aborted', () => upstream.destroy());
71 request.pipe(upstream);
72 });
73 },
74 };
75}