UNPKG

synapse-storage

Version:

Набор инструментов для управления состоянием и апи-запросами

657 lines (635 loc) 30.2 kB
import { loggerConsole } from "../../../_utils/logger-console.util.js"; import { SyncBroadcastChannel } from "./broadcast.util.js"; // Служебное сообщение регистрации порта в воркере (привязка port → channelName) const REGISTER_TYPE = '__worker_channel_register__'; // Служебное сообщение снятия регистрации порта (при close()) — чтобы воркер не держал // закрытые порты в Set вечно (иначе память воркера растёт за цикл открытия/закрытия вкладок). const UNREGISTER_TYPE = '__worker_channel_unregister__'; // ─── Store-RPC протокол (workerKind: 'store') ────────────────────────────────── const RPC_REQUEST_TYPE = '__worker_store_rpc_request__'; const RPC_RESPONSE_TYPE = '__worker_store_rpc_response__'; // Уведомление соседних портов/экземпляров о мутации (для инвалидации кэша в других вкладках). const STORE_MUTATION_TYPE = '__worker_store_mutation__'; // senderId в ответах воркера — не совпадает ни с одним tabId, чтобы не отфильтроваться как «своё». const STORE_WORKER_ID = '__worker_store__'; // Операции, меняющие состояние — после них воркер уведомляет соседей. const MUTATING_OPS = [ 'setAll', 'set', 'update', 'delete', 'clear' ]; /** * Контракт операций key-value стора. Работает поверх обычного `Map<string, unknown>`. * Одна и та же реализация используется и в реальном SharedWorker (инъекцией через * `.toString()`), и во in-process фолбэке — этим гарантируется идентичная семантика. * * `WorkerCacheStorage` общается со стором только через {@link WorkerChannel.request}(op, * payload) по этому op-набору (`get`/`getAll`/`setAll`/`set`/`update`/`delete`/`clear`/ * `keys`/`has`) и не знает, где реально живут данные (SharedWorker-Map / in-process Map). */ function applyStoreOp(store, op, payload) { switch(op){ case 'get': return store.get(payload.key); case 'getAll': { const out = {}; store.forEach((v, k)=>{ out[k] = v; }); return out; } case 'setAll': { store.clear(); const state = payload && payload.state || {}; Object.keys(state).forEach((k)=>store.set(k, state[k])); return undefined; } case 'set': store.set(payload.key, payload.value); return undefined; case 'update': { const entries = payload && payload.entries || []; entries.forEach((e)=>store.set(e.key, e.value)); return undefined; } case 'delete': { const had = store.has(payload.key); store.delete(payload.key); return had; } case 'clear': store.clear(); return undefined; case 'keys': return Array.from(store.keys()); case 'has': return store.has(payload.key); default: throw new Error('Unknown store op: ' + op); } } /** * Исходник воркера как строка. Воркер держит набор подключённых портов * по каждому channelName и пересылает сообщения соседним портам того же * канала (исключая отправителя). Специально минимальный, но корректный. */ function getWorkerSource() { return ` // Карта: channelName -> Set<MessagePort> var channels = new Map(); self.onconnect = function (event) { var port = event.ports[0]; port.onmessage = function (e) { var data = e.data; if (!data || !data.channelName) return; // Регистрация порта в канале if (data.type === '${REGISTER_TYPE}') { var set = channels.get(data.channelName); if (!set) { set = new Set(); channels.set(data.channelName, set); } set.add(port); return; } // Снятие регистрации (порт закрывается) — не держим мёртвый порт в Set. if (data.type === '${UNREGISTER_TYPE}') { var reg = channels.get(data.channelName); if (reg) { reg.delete(port); if (reg.size === 0) channels.delete(data.channelName); } return; } // Пересылаем сообщение соседним портам того же канала (кроме отправителя) var peers = channels.get(data.channelName); if (!peers) return; peers.forEach(function (peer) { if (peer !== port) { peer.postMessage(data); } }); }; port.start(); }; `; } // Кэш blob-URL воркера по умолчанию на уровне модуля. // Важно: один и тот же URL → один и тот же SharedWorker. Если бы мы создавали // новый blob на каждый экземпляр, каждый получил бы отдельный воркер. let defaultWorkerUrl; function getDefaultWorkerUrl() { if (!defaultWorkerUrl) { const source = getWorkerSource(); defaultWorkerUrl = URL.createObjectURL(new Blob([ source ], { type: 'application/javascript' })); } return defaultWorkerUrl; } /** * Исходник STORE-воркера. Держит `Map<channelName, Map<key, value>>` (мультиплексирование * по channelName) и отвечает на RPC-операции ({@link applyStoreOp}). После мутирующей * операции рассылает соседним портам того же канала уведомление STORE_MUTATION, чтобы * другие вкладки могли инвалидировать/обновить свой кэш. * * Логика операций инъектируется из {@link applyStoreOp} через `.toString()`, поэтому * воркер и in-process фолбэк ведут себя одинаково (нет расхождения реализаций). */ function getStoreWorkerSource() { return ` var channels = new Map(); // channelName -> Map(key -> value) var ports = new Map(); // channelName -> Set<MessagePort> var MUTATING = ${JSON.stringify(MUTATING_OPS)}; // Инжектим как БИНДИНГ (var), а не голое объявление: минификатор потребителя // переименовывает объявленную функцию, но вызовы внутри этого строкового // скрипта ссылаются на литеральное имя. Named function expression оставляет // внешнее имя (var) стабильным, а внутреннее имя — локальным. var applyStoreOp = ${applyStoreOp.toString()}; function getStore(name) { var s = channels.get(name); if (!s) { s = new Map(); channels.set(name, s); } return s; } self.onconnect = function (event) { var port = event.ports[0]; port.onmessage = function (e) { var data = e.data; if (!data || !data.channelName) return; if (data.type === '${REGISTER_TYPE}') { var set = ports.get(data.channelName); if (!set) { set = new Set(); ports.set(data.channelName, set); } set.add(port); return; } if (data.type === '${UNREGISTER_TYPE}') { var reg = ports.get(data.channelName); if (reg) { reg.delete(port); if (reg.size === 0) ports.delete(data.channelName); } return; } if (data.type === '${RPC_REQUEST_TYPE}') { var store = getStore(data.channelName); var response = { channelName: data.channelName, type: '${RPC_RESPONSE_TYPE}', senderId: '${STORE_WORKER_ID}', requestId: data.requestId, timestamp: Date.now(), }; try { response.payload = applyStoreOp(store, data.op, data.payload); } catch (err) { response.error = (err && err.message) ? err.message : String(err); } port.postMessage(response); // Уведомляем соседей о мутации (кроме порта-инициатора). if (!response.error && MUTATING.indexOf(data.op) !== -1) { var peers = ports.get(data.channelName); if (peers) { peers.forEach(function (peer) { if (peer !== port) { peer.postMessage({ channelName: data.channelName, type: '${STORE_MUTATION_TYPE}', senderId: data.senderId, payload: { op: data.op }, timestamp: Date.now(), }); } }); } } return; } }; port.start(); }; `; } let storeWorkerUrl; function getStoreWorkerUrl() { if (!storeWorkerUrl) { storeWorkerUrl = URL.createObjectURL(new Blob([ getStoreWorkerSource() ], { type: 'application/javascript' })); } return storeWorkerUrl; } /** * In-process фолбэк для STORE-режима (нет реального SharedWorker: тесты/SSR). * * Map разделяется НА УРОВНЕ МОДУЛЯ по channelName, поэтому два WorkerCacheStorage с * одним channelName в ОДНОМ процессе видят одни данные. ВАЖНО: это НЕ межвкладочный * шеринг — у каждой вкладки свой модуль/процесс. Кросс-табный шеринг даёт только * настоящий SharedWorker. Round-trip при этом идентичен MemoryStorage. */ const localStores = new Map(); const localPeers = new Map(); function getLocalStore(channelName) { let store = localStores.get(channelName); if (!store) { store = new Map(); localStores.set(channelName, store); } return store; } /** * Транспорт поверх SharedWorker/MessagePort, зеркалящий публичный API * SyncBroadcastChannel. Позволяет middleware и адаптерам хранилища использовать * оба класса взаимозаменяемо. * * Мультиплексирование: N хранилищ делят ОДИН SharedWorker и разделяются по * channelName. Если SharedWorker недоступен — прозрачно делегируем в * SyncBroadcastChannel. Если недоступен и BroadcastChannel — no-op (SSR-safe). */ class WorkerChannel { channelName; tabId; messageHandlers; syncHandler; syncTimeoutMs; workerKind; pendingSyncRequests; // Ожидающие RPC-ответы (workerKind: 'store'), ключ — requestId. pendingRequests = new Map(); // Режим работы транспорта. 'local' — in-process стор (store-fallback/SSR). mode; port; // Фолбэк на SyncBroadcastChannel (когда SharedWorker недоступен, relay-режим) fallback; // In-process стор (workerKind: 'store' без SharedWorker) localStore; constructor(channelName, options = {}){ this.channelName = channelName; this.tabId = crypto.randomUUID(); this.messageHandlers = new Set(); this.workerKind = options.workerKind ?? 'relay'; this.syncTimeoutMs = options.requestTimeoutMs ?? 1000; this.pendingSyncRequests = new Map(); if (this.workerKind === 'store') { // STORE-режим: только SharedWorker (кросс-таб) либо in-process стор. // SyncBroadcastChannel здесь не нужен — round-trip обеспечивает стор. if (typeof SharedWorker !== 'undefined' && this.tryConnectSharedWorker(options.workerUrl ?? getStoreWorkerUrl())) { this.mode = 'worker'; return; } // SharedWorker недоступен или конструктор бросил (CSP/SecurityError): // прозрачно деградируем на in-process стор (R6/R9). this.mode = 'local'; this.localStore = getLocalStore(channelName); let peers = localPeers.get(channelName); if (!peers) { peers = new Set(); localPeers.set(channelName, peers); } peers.add(this); return; } if (typeof SharedWorker !== 'undefined' && this.tryConnectSharedWorker(options.workerUrl ?? getDefaultWorkerUrl())) { // Основной путь — SharedWorker this.mode = 'worker'; } else if (typeof BroadcastChannel !== 'undefined') { // Фолбэк — прозрачно делегируем в SyncBroadcastChannel с тем же channelName this.mode = 'fallback'; this.fallback = new SyncBroadcastChannel(channelName); } else { // SSR: ни SharedWorker, ни BroadcastChannel — тихий no-op this.mode = 'noop'; } } /** * Пытается создать SharedWorker и привязать порт. Конструктор `new SharedWorker` * может БРОСИТЬ синхронно (CSP `worker-src` без `blob:` → SecurityError). В этом * случае честно логируем и возвращаем false — вызывающий прозрачно уходит в фолбэк * (BroadcastChannel в relay-режиме, in-process стор в store-режиме), а не роняет * конструирование адаптера/middleware. * * @returns true если SharedWorker успешно подключён, иначе false (нужен фолбэк). */ tryConnectSharedWorker(workerUrl) { try { const worker = new SharedWorker(workerUrl); // Асинхронные ошибки воркера (после конструктора) — не молчим. Частый случай для // кастомного workerUrl: скрипт не загрузился (404/неверный MIME). Конструктор УЖЕ // отработал (mode='worker'), автофолбэка на этой стадии нет — операции будут падать // по таймауту. Даём actionable-диагностику, а не молчаливую деградацию (R9). worker.onerror = (event)=>{ loggerConsole.warn(`[WorkerChannel][${this.tabId}] SharedWorker-скрипт не загрузился/упал (url: ${String(workerUrl)}). ` + `Вероятная причина: недоступный кастомный workerUrl (404 / неверный MIME) или ошибка в скрипте. ` + `Транспорт остаётся в режиме 'worker', но RPC-операции будут падать по таймауту (${this.syncTimeoutMs}мс). ` + `Проверьте, что workerUrl отдаётся same-origin как application/javascript.`, event); }; this.port = worker.port; this.port.onmessage = this.handleMessage.bind(this); this.port.onmessageerror = this.handleError.bind(this); this.port.start(); this.registerPort(); return true; } catch (error) { loggerConsole.warn(`[WorkerChannel][${this.tabId}] SharedWorker недоступен (вероятно CSP worker-src без blob: → SecurityError). ` + `Прозрачно деградирую на ${this.workerKind === 'store' ? 'in-process стор (без кросс-табного шеринга)' : 'BroadcastChannel'}.`, error); return false; } } /** Фактический режим транспорта (для честного `capabilities`/диагностики, R9). */ get transportMode() { return this.mode; } error(...args) { loggerConsole.error(`[WorkerChannel][${this.tabId}]`, ...args); } registerPort() { // Служебное сообщение без payload — только привязка порта к каналу const message = { channelName: this.channelName, type: REGISTER_TYPE, senderId: this.tabId, timestamp: Date.now() }; this.safePostMessage(message); } async handleMessage(event) { const message = event.data; // Чужие каналы игнорируем (страховка — воркер и так разводит по channelName) if (!message || message.channelName !== this.channelName) { return; } // Игнорируем собственные сообщения if (message.senderId === this.tabId) { return; } // Обработка RPC-ответа STORE-воркера (сопоставление по requestId) if (message.type === RPC_RESPONSE_TYPE) { const requestId = message.requestId; if (!requestId) return; const pending = this.pendingRequests.get(requestId); if (pending) { clearTimeout(pending.timeout); this.pendingRequests.delete(requestId); if (message.error) { pending.reject(new Error(message.error)); } else { pending.resolve(message.payload); } } return; } // Обработка запроса на синхронизацию if (message.type === 'SYNC_REQUEST') { if (this.syncHandler) { try { const state = await this.syncHandler(); this.postMessage('SYNC_RESPONSE', state, message.senderId); } catch (error) { this.error('Error handling sync request:', error); } } return; } // Обработка ответа на запрос синхронизации if (message.type === 'SYNC_RESPONSE') { // Фильтруем по targetId — принимаем только ответы, адресованные этой вкладке if (message.targetId && message.targetId !== this.tabId) { return; } const request = this.pendingSyncRequests.get(this.tabId); if (request) { clearTimeout(request.timeout); this.pendingSyncRequests.delete(this.tabId); //@ts-ignore request.resolve(message.payload); } return; } // Уведомляем всех подписчиков о сообщении for (const handler of this.messageHandlers){ try { await handler(message); } catch (error) { this.error('Error in message handler:', error); } } } handleError(event) { this.error('Channel error:', event); } safePostMessage(message) { if (!this.port) { return; } try { // Всё, что уходит в порт, обязано клонироваться structured clone this.port.postMessage(message); } catch (error) { // Даём понятную ошибку вместо тихого сбоя postMessage throw new Error(`[WorkerChannel] Не удалось отправить сообщение "${message.type}": payload не поддерживает structured clone (structured clone algorithm). Передавайте только клонируемые данные. Исходная ошибка: ${error?.message ?? error}`); } } postMessage(type, payload, targetId) { const message = { channelName: this.channelName, type, payload, senderId: this.tabId, timestamp: Date.now() }; if (targetId) { message.targetId = targetId; } this.safePostMessage(message); } /** * Подписка на сообщения канала */ subscribe(handler) { if (this.mode === 'fallback' && this.fallback) { return this.fallback.subscribe(handler); } this.messageHandlers.add(handler); return ()=>this.messageHandlers.delete(handler); } /** * Установка обработчика запросов на синхронизацию */ setSyncHandler(handler) { if (this.mode === 'fallback' && this.fallback) { this.fallback.setSyncHandler(handler); return; } this.syncHandler = handler; } /** * Отправка сообщения всем подписчикам */ broadcast(type, payload) { if (this.mode === 'fallback' && this.fallback) { this.fallback.broadcast(type, payload); return; } //@ts-ignore this.postMessage(type, payload); } /** * Запрос синхронизации данных с других вкладок */ async requestSync() { if (this.mode === 'fallback' && this.fallback) { return this.fallback.requestSync(); } // no-op режим — синхронизироваться не с кем if (this.mode === 'noop') { return null; } return new Promise((resolve, reject)=>{ const timeout = setTimeout(()=>{ this.pendingSyncRequests.delete(this.tabId); resolve(null); }, this.syncTimeoutMs); this.pendingSyncRequests.set(this.tabId, { resolve, reject, timeout }); this.postMessage('SYNC_REQUEST', { type: 'sync' }); }); } /** * RPC-запрос к STORE-бэкенду (workerKind: 'store'). Отправляет операцию с уникальным * requestId и резолвится ответом. По таймауту ({@link WorkerChannelOptions.requestTimeoutMs}, * по умолчанию 1000мс) — reject. * * В worker-режиме payload проходит structured clone через postMessage. В local-режиме * мы клонируем payload/результат вручную — так и валидируется клонируемость, и данные * изолируются (как через границу воркера). */ request(type, payload) { if (this.workerKind !== 'store') { return Promise.reject(new Error(`[WorkerChannel] request() доступен только в workerKind: 'store' (текущий: '${this.workerKind}')`)); } // In-process стор — синхронный round-trip, но возвращаем Promise ради единого API. if (this.mode === 'local' && this.localStore) { let clonedPayload; try { clonedPayload = payload === undefined ? undefined : structuredClone(payload); } catch (error) { return Promise.reject(new Error(`[WorkerChannel] Операция "${type}": payload не поддерживает structured clone (structured clone algorithm). Передавайте только клонируемые данные (не Response/Headers/функции). Исходная ошибка: ${error?.message ?? error}`)); } try { const result = applyStoreOp(this.localStore, type, clonedPayload); if (MUTATING_OPS.indexOf(type) !== -1) { this.notifyLocalPeers(type); } // Клонируем результат — изоляция как через границу воркера. return Promise.resolve(structuredClone(result)); } catch (error) { return Promise.reject(error); } } // Worker-режим — отправляем RPC-сообщение и ждём ответ по requestId. if (this.mode === 'worker') { return new Promise((resolve, reject)=>{ const requestId = crypto.randomUUID(); const timeout = setTimeout(()=>{ this.pendingRequests.delete(requestId); reject(new Error(`[WorkerChannel] RPC "${type}" превысил таймаут ${this.syncTimeoutMs}мс`)); }, this.syncTimeoutMs); this.pendingRequests.set(requestId, { resolve, reject, timeout }); const message = { channelName: this.channelName, type: RPC_REQUEST_TYPE, op: type, requestId, payload: payload, senderId: this.tabId, timestamp: Date.now() }; try { this.safePostMessage(message); } catch (error) { clearTimeout(timeout); this.pendingRequests.delete(requestId); reject(error); } }); } return Promise.reject(new Error('[WorkerChannel] STORE-бэкенд недоступен')); } /** Уведомляет соседние in-process экземпляры того же канала о мутации (кроме себя). */ notifyLocalPeers(op) { const peers = localPeers.get(this.channelName); if (!peers) return; const message = { channelName: this.channelName, type: STORE_MUTATION_TYPE, payload: { op }, senderId: this.tabId, timestamp: Date.now() }; for (const peer of peers){ if (peer === this) continue; for (const handler of peer.messageHandlers){ try { handler(message); } catch (error) { this.error('Error in local peer handler:', error); } } } } /** * Закрытие канала */ close() { // Очищаем все pending запросы for (const [, request] of this.pendingSyncRequests){ clearTimeout(request.timeout); request.reject(new Error('Channel closed')); } this.pendingSyncRequests.clear(); // Очищаем pending RPC-запросы for (const [, request] of this.pendingRequests){ clearTimeout(request.timeout); request.reject(new Error('Channel closed')); } this.pendingRequests.clear(); // Снимаем регистрацию in-process экземпляра (store-fallback). // Данные (localStore) НЕ трогаем — они разделяются между экземплярами канала. if (this.mode === 'local') { const peers = localPeers.get(this.channelName); if (peers) { peers.delete(this); if (peers.size === 0) localPeers.delete(this.channelName); } this.localStore = undefined; } // Очищаем обработчики this.messageHandlers.clear(); this.syncHandler = undefined; // Закрываем фолбэк, если он был if (this.fallback) { this.fallback.close(); this.fallback = undefined; } // Закрываем порт SharedWorker if (this.port) { // Сначала просим воркер снять регистрацию порта — иначе он держит закрытый порт в // Set вечно (postMessage в него — тихий no-op, но память воркера растёт). Отправляем // ДО close(), пока порт ещё живой. this.safePostMessage({ channelName: this.channelName, type: UNREGISTER_TYPE, senderId: this.tabId, timestamp: Date.now() }); this.port.onmessage = null; this.port.onmessageerror = null; this.port.close(); this.port = undefined; } } } export { WorkerChannel }; //# sourceMappingURL=worker-channel.util.js.map