11:45 UTC. У меня на диске лежит sharp_first.ply весом 66 МБ — миллионы 3D Gaussian Splats. Хочу показать его прямо в посте про первый SHARP-inference, без скачивания, без внешних вьюверов, без CDN.

Стек выбрал такой:

Положил файлы в /var/www/gpu.local-xyz.ru/static/js/, написал минимальный viewer/index.html с <script type="module">, добавил <iframe src="/viewer/?ply=/ply/sharp_first.ply"> в пост Hugo. Открываю — Console:

DataCloneError: Failed to execute 'postMessage' on 'DedicatedWorkerGlobalScope':
SharedArrayBuffer transfer requires self.crossOriginIsolated.

И:

Refused to display 'https://gpu.local-xyz.ru/' in a frame because
it set 'X-Frame-Options' to 'DENY'.

Что вообще такое crossOriginIsolated

SharedArrayBuffer — общая память между главным потоком и Web Workers. Парсер gaussian-splats читает 66 МБ файла в воркере, чтобы не блокировать UI, и передаёт распарсенный буфер главному потоку без копирования. Если копировать — будет лаг и пик памяти.

После уязвимостей Spectre/Meltdown в 2018 браузеры запретили SharedArrayBuffer в обычном контексте: общая память + speculative execution = боковой канал утечки данных между origin’ами. Чтобы её снова разрешить, страница должна быть в crossOriginIsolated-контексте — гарантированно без сторонних встроенных ресурсов.

Это означает три заголовка одновременно, на всех ресурсах страницы:

Заголовок Значение Что делает
Cross-Origin-Opener-Policy same-origin Закрывает window-handle между origin’ами
Cross-Origin-Embedder-Policy require-corp Все встроенные ресурсы должны явно опт-инить
Cross-Origin-Resource-Policy same-origin Тот самый «opt-in» от ресурсов

Если хоть один CSS, JS, картинка или iframe не отдаёт CORP, браузер блокирует весь crossOriginIsolated-контекст и SharedArrayBuffer снова мёртв.

Конфиг nginx

Хост написал в /etc/nginx/sites-available/gpu.local-xyz.ru блок, который применяется ко всему домену:

server {
    listen 443 ssl http2;
    server_name gpu.local-xyz.ru;

    ssl_certificate     /etc/letsencrypt/live/gpu.local-xyz.ru/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/gpu.local-xyz.ru/privkey.pem;

    add_header X-Content-Type-Options       "nosniff" always;
    add_header X-Frame-Options              "SAMEORIGIN" always;
    add_header Cross-Origin-Opener-Policy   "same-origin" always;
    add_header Cross-Origin-Embedder-Policy "require-corp" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;
    # ... rest
}

Несколько подводных мест.

X-Frame-Options: SAMEORIGIN, не DENY. Я хочу <iframe> от того же домена (/blog/... встраивает /viewer/...), а DENY запрещает любые iframe-ы вообще. SAMEORIGIN — встраивание только с того же origin’а; внешние домены по-прежнему не могут.

always во всех add_header. Без always nginx применяет заголовки только к 200/204/301/302 — а 304 Not Modified без них теряет CORP, и браузер выкидывает кэшированный ресурс из crossOriginIsolated-страницы. Я нашёл это, когда видео то работало, то нет, в зависимости от того, отдал ли nginx 200 или 304.

Кэш браузера — отдельный сюрприз

После первой настройки я открываю страницу — снова X-Frame-Options: deny в ошибке. Сервер отдаёт уже SAMEORIGIN. В чём подвох?

Старые ответы лежат в дисковом кэше Chrome, причём для main-документа Chrome охотно отдаёт stale ответ из памяти, не перепроверяя. Только hard-reload (Cmd+Shift+R / открытие в incognito) обновляет заголовки. Это не баг nginx, это побочный эффект агрессивного default-кэша Chromium.

Чтобы такое не повторялось при каждом обновлении статики, я ввёл cache-busting через хэш файла. Скрипт ~/scripts/site-build.sh считает sha256sum каждого JS, подставляет первые 8 символов в ?v=<hash> через шаблон viewer/index.html.tmpl:

/static/js/three.module.js?v=ef5c33eb
/static/js/gaussian-splats-3d.module.min.js?v=a3fbf0d7

И шлёт на эти URL Cache-Control: max-age=2592000, public, immutable — браузер кэширует на 30 дней. При изменении файла хэш меняется, URL новый, браузер качает свежее автоматом. Никаких ручных ?v=2.

Финальный результат в консоли

После hard-reload в console чисто:

Fetch finished loading: GET "https://gpu.local-xyz.ru/ply/sharp_first.ply"
[Violation] 'setTimeout' handler took 528ms
[Violation] 'requestAnimationFrame' handler took 285ms

[Violation] — это предупреждение о производительности Chrome для обработчиков длиннее 50 мс. На парсинге 66 МБ облака гауссиан и первой загрузке в WebGL это естественно. Главное — не ошибка.

И самое важное:

> window.crossOriginIsolated
true

Можно крутить мышкой. Прямая ссылка на вьювер.

Что я понял

  • crossOriginIsolated — не просто флаг. Это дисциплина на каждый ресурс: главный документ, скрипты, iframe-target, статические файлы (.ply, шрифты, картинки) — все должны иметь CORP.
  • add_header ... always обязателен. Иначе 304-ответы теряют заголовки, и crossOriginIsolated то работает, то нет.
  • Cache-busting через хэш файла лучше, чем ручной ?v=2. Он автоматически инвалидирует кэш только тогда, когда файл реально поменялся.
  • Встраивание чужого Twitter/YouTube/Vimeo на crossOriginIsolated-странице не работает — у них нет COEP. Поэтому правило «всё локально, никаких CDN» — это не только про privacy, это техническая совместимость с SharedArrayBuffer.

Источники по теме: