Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/url-load-proxy-revalidate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@be-music/worker-play': patch
---

An embed `?track=` or player `?music=` archive from another origin was downloaded in full on every load, because `/__url-load-proxy` answered `no-store` and dropped the host's validators. The proxy now answers `no-cache` and passes `ETag` / `Last-Modified` and the browser's `If-None-Match` / `If-Modified-Since` through, so an unchanged archive comes back as a 304 from the browser's cache.
2 changes: 1 addition & 1 deletion docs/player-web.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -875,7 +875,7 @@ PixiJS scene wiring は browser rendering や WebAudio resource に依存する

## 埋め込みプレビュー

`/embed/?track=<URL>`(`play.be-music.app`、プレビュー環境は `play.preview.be-music.app`)は、譜面のアーカイブを iframe の中でプレビューする。`@be-music/app-play` の 2 つ目のエントリ(`embed/index.html`、`src/embed/`)で、同じ Worker が配信する。Worker は iframe では自動サインインを始めない。アーカイブは `?music=` と同じく、同じオリジンからは直接、別の https オリジンからは `/__url-load-proxy` を経由して取得する。
`/embed/?track=<URL>`(`play.be-music.app`、プレビュー環境は `play.preview.be-music.app`)は、譜面のアーカイブを iframe の中でプレビューする。`@be-music/app-play` の 2 つ目のエントリ(`embed/index.html`、`src/embed/`)で、同じ Worker が配信する。Worker は iframe では自動サインインを始めない。アーカイブは `?music=` と同じく、同じオリジンからは直接、別の https オリジンからは `/__url-load-proxy` を経由して取得する。ブラウザはアーカイブを保存し、読み込むたびに再検証する。プロキシは `no-cache` を付け、配信元の `ETag` / `Last-Modified` とブラウザの条件付きリクエストを中継するので、変わっていないアーカイブはダウンロードし直さない。どちらの検証子も送らない配信元からは毎回ダウンロードする。

```html
<iframe
Expand Down
2 changes: 1 addition & 1 deletion docs/player-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,7 @@ PixiJS scene wiring can remain in `player-browser` when the behavior depends on

## Embedded preview

`/embed/?track=<URL>` (`play.be-music.app`, and `play.preview.be-music.app` for the preview environment) previews a chart archive inside an iframe. It is a second entry of `@be-music/app-play` (`embed/index.html`, `src/embed/`), served by the same Worker; the Worker starts no automatic sign-in for an iframe. The archive is fetched like `?music=`: directly from this origin, through `/__url-load-proxy` from another https origin.
`/embed/?track=<URL>` (`play.be-music.app`, and `play.preview.be-music.app` for the preview environment) previews a chart archive inside an iframe. It is a second entry of `@be-music/app-play` (`embed/index.html`, `src/embed/`), served by the same Worker; the Worker starts no automatic sign-in for an iframe. The archive is fetched like `?music=`: directly from this origin, through `/__url-load-proxy` from another https origin. The browser keeps the archive and revalidates it on each load: the proxy marks it `no-cache` and passes the host's `ETag` / `Last-Modified` and the browser's conditional request through, so an unchanged archive is not downloaded again. A host that sends neither validator is downloaded every time.

```html
<iframe
Expand Down
51 changes: 1 addition & 50 deletions packages/worker-play/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import { handleAuth, readSession, type OIDCEnv } from '@be-music/service-auth-client';
import { isLeaderboardPath } from './leaderboard-paths.ts';
import { handlePlayerSettings, PLAYER_SETTINGS_PATH } from './player-settings.ts';
import { serveURLLoadProxy } from './url-load-proxy.ts';

interface Env extends OIDCEnv {
ASSETS: Fetcher;
Expand Down Expand Up @@ -69,56 +70,6 @@ async function serveFFmpegCore(request: Request, env: Env, pathname: string): Pr
return new Response(request.method === 'HEAD' ? null : object.body, { headers });
}

function resolveURLLoadProxyTarget(rawURL: string | null): URL {
if (!rawURL) {
throw new Error('missing url query parameter');
}
const target = new URL(rawURL);
if (target.protocol !== 'https:') {
throw new Error('only https proxy targets are allowed');
}
if (target.username || target.password) {
throw new Error('proxy target credentials are not allowed');
}
return target;
}

async function serveURLLoadProxy(request: Request): Promise<Response> {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'GET, HEAD' } });
}

let target: URL;
try {
target = resolveURLLoadProxyTarget(new URL(request.url).searchParams.get('url'));
} catch (error) {
return new Response(error instanceof Error ? error.message : 'invalid proxy target', { status: 400 });
}

let upstream: Response;
try {
upstream = await fetch(target, {
method: request.method === 'HEAD' ? 'HEAD' : 'GET',
redirect: 'follow',
});
} catch (error) {
return new Response(`failed to fetch ${target.href}: ${error instanceof Error ? error.message : String(error)}`, {
status: 502,
});
}

const headers = new Headers();
headers.set('content-type', upstream.headers.get('content-type') ?? 'application/octet-stream');
headers.set('cache-control', 'no-store');
headers.set('cross-origin-resource-policy', 'same-origin');

return new Response(request.method === 'HEAD' ? null : upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers,
});
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const auth = await handleAuth(request, env);
Expand Down
74 changes: 74 additions & 0 deletions packages/worker-play/src/url-load-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, test } from 'vite-plus/test';
import { resolveURLLoadProxyTarget, serveURLLoadProxy } from './url-load-proxy.ts';

const ARCHIVE = 'https://example.com/song.zip';
const proxyURL = (url: string) => `https://play.be-music.app/__url-load-proxy?url=${encodeURIComponent(url)}`;

/** A fake upstream recording what it was asked: 304 when the request's If-None-Match is its ETag. */
function upstream(etag: string) {
const requests: { url: string; headers: Headers }[] = [];
const fetcher = async (input: URL, init: RequestInit): Promise<Response> => {
const headers = new Headers(init.headers);
requests.push({ url: input.href, headers });
const validators = { etag, 'last-modified': 'Sat, 31 Oct 2026 00:00:00 GMT' };
if (headers.get('if-none-match') === etag) return new Response(null, { status: 304, headers: validators });
return new Response('zip', { headers: { ...validators, 'content-type': 'application/zip' } });
};
return { requests, fetcher };
}

describe('serveURLLoadProxy', () => {
test('passes the archive through with its validators, cached but revalidated each time', async () => {
const { requests, fetcher } = upstream('"v1"');
const response = await serveURLLoadProxy(new Request(proxyURL(ARCHIVE)), fetcher);
expect(response.status).toBe(200);
expect(await response.text()).toBe('zip');
expect(response.headers.get('cache-control')).toBe('no-cache');
expect(response.headers.get('etag')).toBe('"v1"');
expect(response.headers.get('last-modified')).toBe('Sat, 31 Oct 2026 00:00:00 GMT');
expect(response.headers.get('content-type')).toBe('application/zip');
expect(requests[0]!.url).toBe(ARCHIVE);
});

test("answers an unchanged archive's revalidation with a bodiless 304", async () => {
const { requests, fetcher } = upstream('"v1"');
const request = new Request(proxyURL(ARCHIVE), {
headers: { 'if-none-match': '"v1"', 'if-modified-since': 'Sat, 31 Oct 2026 00:00:00 GMT' },
});
const response = await serveURLLoadProxy(request, fetcher);
expect(response.status).toBe(304);
expect(response.body).toBeNull();
expect(response.headers.get('etag')).toBe('"v1"');
expect(requests[0]!.headers.get('if-none-match')).toBe('"v1"');
expect(requests[0]!.headers.get('if-modified-since')).toBe('Sat, 31 Oct 2026 00:00:00 GMT');
});

test('downloads a changed archive again', async () => {
const { fetcher } = upstream('"v2"');
const request = new Request(proxyURL(ARCHIVE), { headers: { 'if-none-match': '"v1"' } });
const response = await serveURLLoadProxy(request, fetcher);
expect(response.status).toBe(200);
expect(response.headers.get('etag')).toBe('"v2"');
});

test('forwards no other browser header, such as cookies', async () => {
const { requests, fetcher } = upstream('"v1"');
await serveURLLoadProxy(new Request(proxyURL(ARCHIVE), { headers: { cookie: 'session=1' } }), fetcher);
expect([...requests[0]!.headers.keys()]).toEqual([]);
});

test('refuses other methods and targets it must not fetch', async () => {
const { fetcher } = upstream('"v1"');
expect((await serveURLLoadProxy(new Request(proxyURL(ARCHIVE), { method: 'POST' }), fetcher)).status).toBe(405);
expect((await serveURLLoadProxy(new Request(proxyURL('http://example.com/a.zip')), fetcher)).status).toBe(400);
});
});

describe('resolveURLLoadProxyTarget', () => {
test('accepts https URLs only, without credentials', () => {
expect(resolveURLLoadProxyTarget(ARCHIVE).href).toBe(ARCHIVE);
expect(() => resolveURLLoadProxyTarget(null)).toThrow('missing url');
expect(() => resolveURLLoadProxyTarget('http://example.com/a.zip')).toThrow('only https');
expect(() => resolveURLLoadProxyTarget('https://user:pass@example.com/a.zip')).toThrow('credentials');
});
});
75 changes: 75 additions & 0 deletions packages/worker-play/src/url-load-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// `/__url-load-proxy?url=<https URL>`: fetches an archive from another origin for the player's `?music=` and the embed's
// `?track=`, whose hosts often send no CORS headers. The browser may keep the response but revalidates it on every use
// (`no-cache`): the upstream's ETag / Last-Modified pass through, and so do the browser's If-None-Match /
// If-Modified-Since, so an unchanged archive comes back as a 304 instead of downloading again.

/** The validators the proxy carries from the browser to the upstream. */
const CONDITIONAL_REQUEST_HEADERS = ['if-none-match', 'if-modified-since'] as const;
/** The validators the proxy carries from the upstream back to the browser. */
const VALIDATOR_RESPONSE_HEADERS = ['etag', 'last-modified'] as const;

export function resolveURLLoadProxyTarget(rawURL: string | null): URL {
if (!rawURL) {
throw new Error('missing url query parameter');
}
const target = new URL(rawURL);
if (target.protocol !== 'https:') {
throw new Error('only https proxy targets are allowed');
}
if (target.username || target.password) {
throw new Error('proxy target credentials are not allowed');
}
return target;
}

export async function serveURLLoadProxy(
request: Request,
fetcher: (input: URL, init: RequestInit) => Promise<Response> = fetch,
): Promise<Response> {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'GET, HEAD' } });
}

let target: URL;
try {
target = resolveURLLoadProxyTarget(new URL(request.url).searchParams.get('url'));
} catch (error) {
return new Response(error instanceof Error ? error.message : 'invalid proxy target', { status: 400 });
}

const conditional = new Headers();
for (const name of CONDITIONAL_REQUEST_HEADERS) {
const value = request.headers.get(name);
if (value !== null) conditional.set(name, value);
}

let upstream: Response;
try {
upstream = await fetcher(target, {
method: request.method === 'HEAD' ? 'HEAD' : 'GET',
headers: conditional,
redirect: 'follow',
});
} catch (error) {
return new Response(`failed to fetch ${target.href}: ${error instanceof Error ? error.message : String(error)}`, {
status: 502,
});
}

const headers = new Headers();
headers.set('content-type', upstream.headers.get('content-type') ?? 'application/octet-stream');
// Keep it, but ask again each time: the same URL may serve a new archive.
headers.set('cache-control', 'no-cache');
headers.set('cross-origin-resource-policy', 'same-origin');
for (const name of VALIDATOR_RESPONSE_HEADERS) {
const value = upstream.headers.get(name);
if (value !== null) headers.set(name, value);
}

const bodyless = request.method === 'HEAD' || upstream.status === 304;
return new Response(bodyless ? null : upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers,
});
}