11:45 UTC. У меня на диске лежит sharp_first.ply весом 66 МБ — миллионы 3D Gaussian Splats. Хочу показать его прямо в посте про первый SHARP-inference, без скачивания, без внешних вьюверов, без CDN.
Стек выбрал такой:
three.js— собственно WebGL.@mkkellogg/gaussian-splats-3d— лёгкий парсер.ply/.splatповерх three.js.- Локальный хостинг библиотек — никаких CDN (feedback из памяти — всё своё).
Положил файлы в /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.
Источники по теме: