jellyfin sidecar: server registry, video façade and byte pass-through
officer-jellyfin owns the whole Jellyfin contract: the instance URL, the access token, the Jellyfin user it belongs to and the DeviceId its sessions are keyed by. The platform side is a 17-line proxy holding no credentials. Servers are a registry, not a single row — this machine runs four instances and the owner switches between them. The password is never stored: it is traded once for an access token through AuthenticateByName, and only that token is persisted, encrypted. Two doors. /_officer/* is a hand-written JSON façade for the things the browser should not have to know — the user id in the path, the Fields lists that decide whether a grid has posters, the PlaybackInfo negotiation. /_jf/* is a GET-only, allow-listed byte pass-through for images, video, HLS and subtitles; it keeps Jellyfin's own paths because a master playlist references its segments relatively, so any renaming would mean rewriting m3u8 bodies. TranscodingUrl arrives with api_key=<access token> in its query string and would otherwise be handed straight to a video element. It is stripped before anything is returned; the pass-through re-adds the credential as a header. Video only — Officer's own player owns audio, so music collections are filtered out of the library list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
import type { UpstreamConfig } from './upstream';
|
||||
import { DEVICE_PROFILE } from './profile';
|
||||
import { callUpstream } from './upstream';
|
||||
|
||||
// The two halves of the Jellyfin surface Officer exposes.
|
||||
//
|
||||
// `/_officer/*` — a small hand-written façade. Every route here exists because the raw Jellyfin call needs
|
||||
// something the browser should not have to know: the user id in the path, a Fields list that decides whether
|
||||
// a grid has posters, or a PlaybackInfo negotiation. It is JSON in, JSON out.
|
||||
//
|
||||
// `/_jf/*` — a transparent, authenticated, GET-only pass-through for BYTES: images, video, HLS playlists and
|
||||
// segments, subtitles. It exists because HLS cannot be faked. A master playlist references its variant
|
||||
// playlists and every segment RELATIVELY, so the URLs a browser derives from it must resolve back onto the
|
||||
// same prefix; any façade that renamed those paths would have to rewrite the playlists on the way out, and
|
||||
// rewriting m3u8 bodies to keep a prettier URL scheme is a trade nobody should take.
|
||||
//
|
||||
// The pass-through is deny-by-default over a prefix allow-list. Jellyfin's API is the whole server —
|
||||
// `/System/*`, `/ScheduledTasks/*`, `/Plugins/*`, user administration, the setup wizard — and the token this
|
||||
// sidecar holds belongs to a real account, so an "everything under /_jf" proxy would put library scanning and
|
||||
// user creation one URL away from the browser.
|
||||
//
|
||||
// One thing here is a security fix rather than plumbing: `TranscodingUrl` comes back from Jellyfin with
|
||||
// `api_key=<the access token>` embedded in its query string. That URL is handed to the video element, so it
|
||||
// would put the token in the DOM, in history and in any log that records URLs. It is stripped in
|
||||
// `sanitizeUpstreamPath` before anything is returned, and the pass-through re-adds the credential as a header
|
||||
// where it belongs.
|
||||
|
||||
/** Ticks are 100-nanosecond units. Jellyfin speaks these everywhere; the UI speaks seconds. */
|
||||
const TICKS_PER_SECOND = 10_000_000;
|
||||
|
||||
const json = (data: unknown, status = 200) => Response.json(data, { status });
|
||||
const bad = (error: string, status = 400) => json({ error }, status);
|
||||
|
||||
/** Pass a Jellyfin JSON response through, keeping its status so a 404 upstream stays a 404 here. */
|
||||
async function relayJson(res: Response): Promise<Response> {
|
||||
const text = await res.text();
|
||||
return new Response(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
/** GET a Jellyfin path as parsed JSON, or throw with the upstream status attached. */
|
||||
async function fetchJson<T>(cfg: UpstreamConfig, path: string, params: Record<string, string | undefined>): Promise<T> {
|
||||
const res = await callUpstream(cfg, { path, query: buildQuery(params) });
|
||||
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function buildQuery(params: Record<string, string | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value != null && value !== '') search.set(key, value);
|
||||
}
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
// The Fields lists. A grid without ImageTags renders grey rectangles, which is the single most common way a
|
||||
// Jellyfin client looks broken, so the poster-bearing fields are not optional anywhere.
|
||||
const GRID_FIELDS = 'PrimaryImageAspectRatio,BasicSyncInfo,ProductionYear,Status,EndDate';
|
||||
const DETAIL_FIELDS =
|
||||
'Overview,Genres,Studios,People,Taglines,ProductionYear,OfficialRating,CommunityRating,MediaSources,MediaStreams,Chapters,ExternalUrls,RemoteTrailers,PrimaryImageAspectRatio,ParentId,SeriesStudio';
|
||||
|
||||
/**
|
||||
* The libraries worth showing.
|
||||
*
|
||||
* Music is filtered OUT on purpose and not as a stylistic choice: the owner's audio lives in Officer's own
|
||||
* /music player, and this machine runs separate Jellyfin instances for albums and DJ sets. A Jellyfin panel
|
||||
* that also listed those would be two music libraries competing for the same job.
|
||||
*/
|
||||
const VIDEO_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'homevideos', 'musicvideos', 'boxsets', 'playlists']);
|
||||
|
||||
type JellyfinItem = { Id?: string; Name?: string; CollectionType?: string; Type?: string };
|
||||
type ItemsResponse = { Items?: JellyfinItem[]; TotalRecordCount?: number };
|
||||
|
||||
async function videoViews(cfg: UpstreamConfig): Promise<JellyfinItem[]> {
|
||||
const data = await fetchJson<ItemsResponse>(cfg, `/Users/${cfg.jellyfinUserId}/Views`, {});
|
||||
return (data.Items ?? []).filter((view) => !view.CollectionType || VIDEO_COLLECTION_TYPES.has(view.CollectionType));
|
||||
}
|
||||
|
||||
/**
|
||||
* The home screen in ONE round trip: libraries, what is half-watched, what is next, and the newest thing in
|
||||
* each library. Jellyfin needs four different calls for that, and issuing them from the browser would mean
|
||||
* four proxy hops and a screen that assembles itself in visible stages.
|
||||
*
|
||||
* Latest-per-library is fetched concurrently and failures are swallowed per library: one library that is
|
||||
* mid-scan should cost its own shelf, not the whole page.
|
||||
*/
|
||||
async function home(cfg: UpstreamConfig): Promise<Response> {
|
||||
const views = await videoViews(cfg);
|
||||
|
||||
const [resume, nextUp, latest] = await Promise.all([
|
||||
fetchJson<ItemsResponse>(cfg, '/UserItems/Resume', {
|
||||
userId: cfg.jellyfinUserId,
|
||||
limit: '12',
|
||||
mediaTypes: 'Video',
|
||||
fields: GRID_FIELDS,
|
||||
enableTotalRecordCount: 'false',
|
||||
}).catch(() => ({ Items: [] })),
|
||||
fetchJson<ItemsResponse>(cfg, '/Shows/NextUp', {
|
||||
userId: cfg.jellyfinUserId,
|
||||
limit: '12',
|
||||
fields: GRID_FIELDS,
|
||||
enableTotalRecordCount: 'false',
|
||||
}).catch(() => ({ Items: [] })),
|
||||
Promise.all(
|
||||
views.map(async (view) => ({
|
||||
viewId: view.Id ?? '',
|
||||
viewName: view.Name ?? '',
|
||||
collectionType: view.CollectionType ?? null,
|
||||
items: await fetchJson<JellyfinItem[]>(cfg, `/Users/${cfg.jellyfinUserId}/Items/Latest`, {
|
||||
parentId: view.Id,
|
||||
limit: '12',
|
||||
fields: GRID_FIELDS,
|
||||
}).catch(() => []),
|
||||
})),
|
||||
),
|
||||
]);
|
||||
|
||||
return json({ views, resume: resume.Items ?? [], nextUp: nextUp.Items ?? [], latest });
|
||||
}
|
||||
|
||||
// What a browse grid is allowed to ask for. Deny-by-default again — `/Items` accepts filters that reach
|
||||
// outside the library (`path`, `userId`) and this list is what keeps the query the UI's business only.
|
||||
const ITEM_QUERY_PARAMS = [
|
||||
'parentId',
|
||||
'includeItemTypes',
|
||||
'excludeItemTypes',
|
||||
'recursive',
|
||||
'sortBy',
|
||||
'sortOrder',
|
||||
'startIndex',
|
||||
'limit',
|
||||
'searchTerm',
|
||||
'filters',
|
||||
'genres',
|
||||
'genreIds',
|
||||
'years',
|
||||
'officialRatings',
|
||||
'tags',
|
||||
'studioIds',
|
||||
'personIds',
|
||||
'isPlayed',
|
||||
'isFavorite',
|
||||
'nameStartsWith',
|
||||
'imageTypeLimit',
|
||||
'enableImageTypes',
|
||||
'collapseBoxSetItems',
|
||||
] as const;
|
||||
|
||||
function passthroughItemQuery(url: URL): Record<string, string | undefined> {
|
||||
const params: Record<string, string | undefined> = {};
|
||||
for (const key of ITEM_QUERY_PARAMS) {
|
||||
const value = url.searchParams.get(key);
|
||||
if (value != null) params[key] = value;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/** A browse grid. Defaults to recursive video items so a library id is enough to get something sensible. */
|
||||
async function items(cfg: UpstreamConfig, url: URL): Promise<Response> {
|
||||
const params = passthroughItemQuery(url);
|
||||
const res = await callUpstream(cfg, {
|
||||
path: '/Items',
|
||||
query: buildQuery({
|
||||
userId: cfg.jellyfinUserId,
|
||||
recursive: params.recursive ?? 'true',
|
||||
includeItemTypes: params.includeItemTypes ?? 'Movie,Series,Video',
|
||||
sortBy: params.sortBy ?? 'SortName',
|
||||
sortOrder: params.sortOrder ?? 'Ascending',
|
||||
limit: params.limit ?? '100',
|
||||
fields: GRID_FIELDS,
|
||||
imageTypeLimit: '1',
|
||||
enableImageTypes: 'Primary,Backdrop,Thumb,Logo',
|
||||
...params,
|
||||
}),
|
||||
});
|
||||
return relayJson(res);
|
||||
}
|
||||
|
||||
/** One item, with everything the detail page draws — including MediaSources, which drive the codec line. */
|
||||
async function item(cfg: UpstreamConfig, id: string): Promise<Response> {
|
||||
const res = await callUpstream(cfg, {
|
||||
path: `/Items/${encodeURIComponent(id)}`,
|
||||
query: buildQuery({ userId: cfg.jellyfinUserId, fields: DETAIL_FIELDS }),
|
||||
});
|
||||
return relayJson(res);
|
||||
}
|
||||
|
||||
type MediaSource = {
|
||||
Id?: string;
|
||||
SupportsDirectPlay?: boolean;
|
||||
SupportsDirectStream?: boolean;
|
||||
TranscodingUrl?: string;
|
||||
};
|
||||
type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null };
|
||||
|
||||
/**
|
||||
* Strip the credential Jellyfin embeds in the URLs it hands back.
|
||||
*
|
||||
* `TranscodingUrl` arrives as `/videos/…/main.m3u8?…&api_key=<access token>&…`. Returning it untouched would
|
||||
* publish the token to the browser. The pass-through supplies the token as a header on every hop, so the
|
||||
* parameter is not merely unsafe, it is redundant.
|
||||
*/
|
||||
function sanitizeUpstreamPath(raw: string): string {
|
||||
const [path, search = ''] = raw.split('?');
|
||||
const params = new URLSearchParams(search);
|
||||
params.delete('api_key');
|
||||
params.delete('ApiKey');
|
||||
params.delete('X-Emby-Token');
|
||||
const qs = params.toString();
|
||||
return `${path}${qs ? `?${qs}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Negotiate playback: ask the server what it can do with this file for this profile, and turn the answer into
|
||||
* one URL the player can use.
|
||||
*
|
||||
* The three outcomes are direct play (the original container is browser-playable), direct stream (remuxed on
|
||||
* the fly, still `/Videos/{id}/stream`) and transcode (HLS). They are reported explicitly rather than
|
||||
* inferred, because "why is my CPU pinned" is a question the UI should be able to answer.
|
||||
*/
|
||||
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
|
||||
const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0');
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
mediaSourceId?: string;
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
maxStreamingBitrate?: number;
|
||||
};
|
||||
|
||||
const res = await callUpstream(cfg, {
|
||||
path: `/Items/${encodeURIComponent(id)}/PlaybackInfo`,
|
||||
method: 'POST',
|
||||
query: buildQuery({ userId: cfg.jellyfinUserId }),
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
DeviceProfile: DEVICE_PROFILE,
|
||||
UserId: cfg.jellyfinUserId,
|
||||
MaxStreamingBitrate: body.maxStreamingBitrate ?? DEVICE_PROFILE.MaxStreamingBitrate,
|
||||
StartTimeTicks: Math.max(0, Math.round(startSeconds * TICKS_PER_SECOND)),
|
||||
MediaSourceId: body.mediaSourceId,
|
||||
AudioStreamIndex: body.audioStreamIndex,
|
||||
SubtitleStreamIndex: body.subtitleStreamIndex,
|
||||
EnableDirectPlay: true,
|
||||
EnableDirectStream: true,
|
||||
EnableTranscoding: true,
|
||||
AllowVideoStreamCopy: true,
|
||||
AllowAudioStreamCopy: true,
|
||||
// Without this the transcode is only prepared, not started, and the first segment request 404s.
|
||||
AutoOpenLiveStream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) return relayJson(res);
|
||||
|
||||
const info = (await res.json()) as PlaybackInfoResponse;
|
||||
const source = info.MediaSources?.find((s) => s.Id === body.mediaSourceId) ?? info.MediaSources?.[0];
|
||||
if (!source) return bad('the server returned no playable source for this item', 502);
|
||||
|
||||
const playSessionId = info.PlaySessionId ?? null;
|
||||
const direct = source.SupportsDirectPlay || source.SupportsDirectStream;
|
||||
|
||||
const path = direct
|
||||
? `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
|
||||
static: 'true',
|
||||
mediaSourceId: source.Id,
|
||||
playSessionId: playSessionId ?? undefined,
|
||||
})}`
|
||||
: source.TranscodingUrl
|
||||
? sanitizeUpstreamPath(source.TranscodingUrl)
|
||||
: null;
|
||||
|
||||
if (!path) {
|
||||
return bad(info.ErrorCode ? `playback refused: ${info.ErrorCode}` : 'the server offered no way to play this', 502);
|
||||
}
|
||||
|
||||
return json({
|
||||
playMethod: direct ? (source.SupportsDirectPlay ? 'DirectPlay' : 'DirectStream') : 'Transcode',
|
||||
// Relative to the panel's mount, so the browser prefixes /api/jellyfin and the pass-through does the rest.
|
||||
url: `/_jf${path}`,
|
||||
isHls: !direct,
|
||||
playSessionId,
|
||||
mediaSourceId: source.Id ?? null,
|
||||
mediaSource: source,
|
||||
startSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
/** Playback reporting — start, heartbeat, stop. Bodies are the upstream's own shapes, forwarded as sent. */
|
||||
async function reportPlaystate(cfg: UpstreamConfig, action: string, req: Request): Promise<Response> {
|
||||
const path =
|
||||
action === 'playing'
|
||||
? '/Sessions/Playing'
|
||||
: action === 'progress'
|
||||
? '/Sessions/Playing/Progress'
|
||||
: action === 'stopped'
|
||||
? '/Sessions/Playing/Stopped'
|
||||
: null;
|
||||
if (!path) return bad('not found', 404);
|
||||
|
||||
const body = await req.text();
|
||||
const res = await callUpstream(cfg, { path, method: 'POST', contentType: 'application/json', body });
|
||||
// 204 with no body is the normal answer; relaying it as JSON would invent content that is not there.
|
||||
return new Response(null, { status: res.status });
|
||||
}
|
||||
|
||||
/** Watched and favourite marks. POST sets, DELETE clears — the same shape Jellyfin's own routes use. */
|
||||
async function userItemFlag(cfg: UpstreamConfig, kind: 'played' | 'favorite', id: string, method: string) {
|
||||
const base = kind === 'played' ? '/UserPlayedItems' : '/UserFavoriteItems';
|
||||
const res = await callUpstream(cfg, {
|
||||
path: `${base}/${encodeURIComponent(id)}`,
|
||||
method: method === 'DELETE' ? 'DELETE' : 'POST',
|
||||
query: buildQuery({ userId: cfg.jellyfinUserId }),
|
||||
});
|
||||
return relayJson(res);
|
||||
}
|
||||
|
||||
/** The `/_officer/*` façade. Returns null when nothing matched, so index.ts can answer a single 404. */
|
||||
export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
|
||||
const [head, a, b] = url.pathname.slice('/_officer/'.length).split('/');
|
||||
const method = req.method;
|
||||
|
||||
if (head === 'home' && method === 'GET') return home(cfg);
|
||||
|
||||
if (head === 'views' && method === 'GET') return json({ views: await videoViews(cfg) });
|
||||
|
||||
if (head === 'items' && !a && method === 'GET') return items(cfg, url);
|
||||
|
||||
if (head === 'items' && a) {
|
||||
if (!b && method === 'GET') return item(cfg, a);
|
||||
if (b === 'similar' && method === 'GET') {
|
||||
const res = await callUpstream(cfg, {
|
||||
path: `/Items/${encodeURIComponent(a)}/Similar`,
|
||||
query: buildQuery({ userId: cfg.jellyfinUserId, limit: '12', fields: GRID_FIELDS }),
|
||||
});
|
||||
return relayJson(res);
|
||||
}
|
||||
if (b === 'playback' && method === 'POST') return playbackInfo(cfg, a, req, url);
|
||||
if ((b === 'played' || b === 'favorite') && (method === 'POST' || method === 'DELETE')) {
|
||||
return userItemFlag(cfg, b, a, method);
|
||||
}
|
||||
}
|
||||
|
||||
if (head === 'shows' && a && method === 'GET') {
|
||||
if (b === 'seasons') {
|
||||
const res = await callUpstream(cfg, {
|
||||
path: `/Shows/${encodeURIComponent(a)}/Seasons`,
|
||||
query: buildQuery({ userId: cfg.jellyfinUserId, fields: GRID_FIELDS }),
|
||||
});
|
||||
return relayJson(res);
|
||||
}
|
||||
if (b === 'episodes') {
|
||||
const res = await callUpstream(cfg, {
|
||||
path: `/Shows/${encodeURIComponent(a)}/Episodes`,
|
||||
query: buildQuery({
|
||||
userId: cfg.jellyfinUserId,
|
||||
seasonId: url.searchParams.get('seasonId') ?? undefined,
|
||||
fields: `${GRID_FIELDS},Overview,MediaSources`,
|
||||
}),
|
||||
});
|
||||
return relayJson(res);
|
||||
}
|
||||
}
|
||||
|
||||
if (head === 'resume' && method === 'GET') {
|
||||
const res = await callUpstream(cfg, {
|
||||
path: '/UserItems/Resume',
|
||||
query: buildQuery({ userId: cfg.jellyfinUserId, limit: '24', mediaTypes: 'Video', fields: GRID_FIELDS }),
|
||||
});
|
||||
return relayJson(res);
|
||||
}
|
||||
|
||||
if (head === 'nextup' && method === 'GET') {
|
||||
const res = await callUpstream(cfg, {
|
||||
path: '/Shows/NextUp',
|
||||
query: buildQuery({ userId: cfg.jellyfinUserId, limit: '24', fields: GRID_FIELDS }),
|
||||
});
|
||||
return relayJson(res);
|
||||
}
|
||||
|
||||
if (head === 'search' && method === 'GET') {
|
||||
const term = url.searchParams.get('q')?.trim();
|
||||
if (!term) return json({ Items: [], TotalRecordCount: 0 });
|
||||
const res = await callUpstream(cfg, {
|
||||
path: '/Items',
|
||||
query: buildQuery({
|
||||
userId: cfg.jellyfinUserId,
|
||||
searchTerm: term,
|
||||
recursive: 'true',
|
||||
includeItemTypes: 'Movie,Series,Episode,Video,BoxSet,Person',
|
||||
limit: '48',
|
||||
fields: GRID_FIELDS,
|
||||
}),
|
||||
});
|
||||
return relayJson(res);
|
||||
}
|
||||
|
||||
if (head === 'genres' && method === 'GET') {
|
||||
const res = await callUpstream(cfg, {
|
||||
path: '/Genres',
|
||||
query: buildQuery({ userId: cfg.jellyfinUserId, parentId: url.searchParams.get('parentId') ?? undefined }),
|
||||
});
|
||||
return relayJson(res);
|
||||
}
|
||||
|
||||
if (head === 'sessions' && a && method === 'POST') return reportPlaystate(cfg, a, req);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// The pass-through allow-list. Anything not matching one of these is refused, including every administrative
|
||||
// route. Matched case-insensitively because Jellyfin's own HLS playlists reference `/videos/…` in lower case
|
||||
// while its OpenAPI document says `/Videos/…`, and a browser resolving a relative segment URL will send back
|
||||
// whatever the playlist said.
|
||||
const BYTES_PREFIXES = [
|
||||
'items/', // artwork: /Items/{id}/Images/{type}
|
||||
'videos/', // direct stream, HLS playlists and segments, subtitles
|
||||
'users/', // /Users/{id}/Images/Primary — the account avatar
|
||||
'persons/', // cast portraits
|
||||
'studios/',
|
||||
'genres/',
|
||||
'musicgenres/',
|
||||
];
|
||||
|
||||
const isAllowedBytesPath = (path: string): boolean => {
|
||||
const lower = path.toLowerCase();
|
||||
return BYTES_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
||||
};
|
||||
|
||||
/**
|
||||
* `/_jf/*` — bytes only, GET only, allow-listed.
|
||||
*
|
||||
* `Accept: * / *` matters: the default JSON Accept makes Jellyfin answer some image routes with a JSON error
|
||||
* instead of the picture. Range is forwarded so seeking works, and the response headers are relayed nearly
|
||||
* whole — content-range, etag and cache-control are what make the `<video>` element and the browser cache
|
||||
* behave, and dropping them turns seeking into a re-download.
|
||||
*/
|
||||
export async function handleBytesRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return null;
|
||||
|
||||
const subpath = url.pathname.slice('/_jf/'.length);
|
||||
if (!subpath || subpath.includes('..')) return null;
|
||||
if (!isAllowedBytesPath(subpath)) return null;
|
||||
|
||||
const upstream = await callUpstream(cfg, {
|
||||
path: `/${subpath}`,
|
||||
method: req.method,
|
||||
query: url.search,
|
||||
accept: '*/*',
|
||||
range: req.headers.get('range'),
|
||||
});
|
||||
|
||||
const headers = new Headers();
|
||||
for (const header of [
|
||||
'content-type',
|
||||
'content-length',
|
||||
'content-range',
|
||||
'accept-ranges',
|
||||
'etag',
|
||||
'last-modified',
|
||||
'cache-control',
|
||||
] as const) {
|
||||
const value = upstream.headers.get(header);
|
||||
if (value) headers.set(header, value);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, { status: upstream.status, headers });
|
||||
}
|
||||
Reference in New Issue
Block a user