Таблицы тональных кривых в WASM-модуле лежали в статическом массиве, который линковщик помещал в сегмент .data — такой сегмент в wasm доступен только для чтения, поэтому запись в него не срабатывала, и модуль считал по мусорным данным (отсюда «каша» вместо плавного градиента). Теперь таблицы считает и передаёт JS: модуль не хранит состояния и только применяет правки к пикселям. Дополнительно клиент сверяет модуль с эталонной JS-реализацией на маленьком образце и, если они расходятся, доверяет JS. Service worker больше не кэширует .wasm: старый модуль в кэше ломал предпросмотр даже после обновления сборки.
229 lines
7.1 KiB
TypeScript
229 lines
7.1 KiB
TypeScript
/// <reference types="@sveltejs/kit" />
|
|
/// <reference types="vite/client" />
|
|
/// <reference lib="webworker" />
|
|
|
|
import { build, files, prerendered, version } from '$service-worker';
|
|
import {
|
|
DATA_CACHE,
|
|
DATA_CACHE_MAX_ENTRIES,
|
|
IMAGE_CACHE,
|
|
IMAGE_CACHE_MAX_BYTES,
|
|
IMAGE_CACHE_MAX_ENTRIES,
|
|
SHELL_CACHE_PREFIX,
|
|
isCacheableData,
|
|
isCacheableObject
|
|
} from '$lib/offline';
|
|
|
|
const sw = self as unknown as ServiceWorkerGlobalScope;
|
|
|
|
// В dev-режиме ничего не кешируем: файлы приложения часто меняются.
|
|
const DEV = import.meta.env.DEV;
|
|
|
|
const SHELL_CACHE = `${SHELL_CACHE_PREFIX}${version}`;
|
|
|
|
/** Оболочка приложения: собранный бандл, статика и запасная страница. */
|
|
const SHELL = [...build, ...files, ...prerendered, '/'];
|
|
|
|
sw.addEventListener('install', (event) => {
|
|
if (DEV) return;
|
|
event.waitUntil(
|
|
(async () => {
|
|
const cache = await caches.open(SHELL_CACHE);
|
|
await Promise.all(
|
|
SHELL.map(async (url) => {
|
|
try {
|
|
const res = await fetch(url, { cache: 'reload' });
|
|
if (res.ok) await cache.put(url, res);
|
|
} catch {
|
|
/* отдельный файл не загрузился — пропускаем */
|
|
}
|
|
})
|
|
);
|
|
})()
|
|
);
|
|
});
|
|
|
|
sw.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
(async () => {
|
|
const keys = await caches.keys();
|
|
await Promise.all(
|
|
keys
|
|
.filter((key) => key.startsWith(SHELL_CACHE_PREFIX) && key !== SHELL_CACHE)
|
|
.map((key) => caches.delete(key))
|
|
);
|
|
await sw.clients.claim();
|
|
})()
|
|
);
|
|
});
|
|
|
|
sw.addEventListener('message', (event) => {
|
|
if (event.data === 'clear-offline-cache') {
|
|
event.waitUntil(Promise.all([caches.delete(IMAGE_CACHE), caches.delete(DATA_CACHE)]));
|
|
}
|
|
});
|
|
|
|
sw.addEventListener('fetch', (event) => {
|
|
if (DEV) return;
|
|
const request = event.request;
|
|
if (request.method !== 'GET') return;
|
|
|
|
const url = new URL(request.url);
|
|
if (url.origin !== sw.location.origin) return;
|
|
|
|
// Поток событий (SSE) не перехватываем.
|
|
if (url.pathname.startsWith('/api/sse/')) return;
|
|
|
|
if (url.pathname.startsWith('/api/objects/') || url.pathname.startsWith('/api/avatars/')) {
|
|
event.respondWith(handleImage(request, url));
|
|
return;
|
|
}
|
|
if (url.pathname.startsWith('/api/')) {
|
|
event.respondWith(handleData(request, url));
|
|
return;
|
|
}
|
|
if (request.mode === 'navigate') {
|
|
event.respondWith(handleNavigation(request));
|
|
return;
|
|
}
|
|
event.respondWith(handleAsset(request));
|
|
});
|
|
|
|
/**
|
|
* Картинки: кеш по пути без подписи. Подпись объекта обновляется каждые
|
|
* десять минут, но содержимое объекта неизменно, поэтому ключ кеша не должен
|
|
* зависеть от строки запроса.
|
|
*/
|
|
async function handleImage(request: Request, url: URL): Promise<Response> {
|
|
// Частичные запросы (видео) не кешируем.
|
|
if (request.headers.has('range')) return fetch(request);
|
|
if (!isCacheableObject(url.pathname)) return fetch(request);
|
|
|
|
const cache = await caches.open(IMAGE_CACHE);
|
|
const key = url.origin + url.pathname;
|
|
const cached = await cache.match(key);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
await cache.put(key, response.clone());
|
|
scheduleTrim();
|
|
}
|
|
return response;
|
|
} catch {
|
|
return new Response('', { status: 504, statusText: 'Offline' });
|
|
}
|
|
}
|
|
|
|
/** Ответы API: сеть вперёд, при её отсутствии — последняя сохранённая копия. */
|
|
async function handleData(request: Request, url: URL): Promise<Response> {
|
|
if (!isCacheableData(url.pathname)) return fetch(request);
|
|
|
|
const cache = await caches.open(DATA_CACHE);
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
await cache.put(request, response.clone());
|
|
scheduleTrim();
|
|
}
|
|
return response;
|
|
} catch {
|
|
const cached = await cache.match(request);
|
|
if (cached) return cached;
|
|
return new Response(
|
|
JSON.stringify({ error: { code: 'offline', message: 'Нет соединения с сервером' } }),
|
|
{ status: 503, headers: { 'content-type': 'application/json' } }
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Переходы: сеть, а без неё — сохранённая оболочка приложения. */
|
|
async function handleNavigation(request: Request): Promise<Response> {
|
|
try {
|
|
return await fetch(request);
|
|
} catch {
|
|
const cache = await caches.open(SHELL_CACHE);
|
|
const cached = await cache.match('/');
|
|
if (cached) return cached;
|
|
return new Response('Офлайн', {
|
|
status: 503,
|
|
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Статика приложения: сначала кеш, затем сеть с дозаписью. */
|
|
async function handleAsset(request: Request): Promise<Response> {
|
|
const cache = await caches.open(SHELL_CACHE);
|
|
// wasm-модуль фильтров меняется вместе с ними: старый кеш сломал бы
|
|
// предпросмотр правок, поэтому его всегда берём из сети.
|
|
if (new URL(request.url).pathname.endsWith('.wasm')) {
|
|
try {
|
|
const fresh = await fetch(request, { cache: 'no-store' });
|
|
if (fresh.ok) await cache.put(request, fresh.clone());
|
|
return fresh;
|
|
} catch {
|
|
const stale = await cache.match(request);
|
|
if (stale) return stale;
|
|
}
|
|
}
|
|
const cached = await cache.match(request);
|
|
if (cached) return cached;
|
|
const response = await fetch(request);
|
|
const type = response.headers.get('content-type') ?? '';
|
|
// Запасная страница SPA не должна отдаваться вместо статики.
|
|
if (response.ok && type.includes('text/html')) {
|
|
return new Response('', { status: 404, statusText: 'Not Found' });
|
|
}
|
|
if (response.ok) await cache.put(request, response.clone());
|
|
return response;
|
|
}
|
|
|
|
let trimCounter = 0;
|
|
|
|
/** Периодически ужимаем кеш картинок, вытесняя самые старые записи. */
|
|
function scheduleTrim() {
|
|
trimCounter += 1;
|
|
if (trimCounter < 20) return;
|
|
trimCounter = 0;
|
|
void trimImageCache();
|
|
void trimDataCache();
|
|
}
|
|
|
|
async function trimImageCache() {
|
|
const cache = await caches.open(IMAGE_CACHE);
|
|
const keys = await cache.keys();
|
|
if (!keys.length) return;
|
|
|
|
const sizes: number[] = [];
|
|
let total = 0;
|
|
for (const key of keys) {
|
|
const response = await cache.match(key);
|
|
const size = Number(response?.headers.get('content-length') ?? 0);
|
|
sizes.push(size);
|
|
total += size;
|
|
}
|
|
|
|
let index = 0;
|
|
while (
|
|
index < keys.length &&
|
|
(total > IMAGE_CACHE_MAX_BYTES || keys.length - index > IMAGE_CACHE_MAX_ENTRIES)
|
|
) {
|
|
await cache.delete(keys[index]);
|
|
total -= sizes[index];
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
/** Ужимаем кеш ответов API: остаются последние сохранённые списки. */
|
|
async function trimDataCache() {
|
|
const cache = await caches.open(DATA_CACHE);
|
|
const keys = await cache.keys();
|
|
let index = 0;
|
|
while (index < keys.length && keys.length - index > DATA_CACHE_MAX_ENTRIES) {
|
|
await cache.delete(keys[index]);
|
|
index += 1;
|
|
}
|
|
}
|