mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
expose Ignis API, implement shared ws client
This commit is contained in:
@@ -8,6 +8,7 @@ import { createWatcherClient } from "./watcher-client.js";
|
||||
import { createFdOps } from "./fd.js";
|
||||
import { constants } from "./constants.js";
|
||||
import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js";
|
||||
import { wsClient } from "../ws-client.js";
|
||||
|
||||
const metadataCache = new MetadataCache();
|
||||
const contentCache = new ContentCache();
|
||||
@@ -15,7 +16,7 @@ const contentCache = new ContentCache();
|
||||
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
|
||||
const fsSync = createFsSync(metadataCache, contentCache, transport);
|
||||
const fsWatch = createFsWatch(transport);
|
||||
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch);
|
||||
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient);
|
||||
const fdOps = createFdOps(metadataCache, contentCache, transport);
|
||||
|
||||
export const fsShim = {
|
||||
|
||||
@@ -1,143 +1,83 @@
|
||||
// Client-side WebSocket file watcher.
|
||||
// Connects to the server's /ws endpoint, receives file change events,
|
||||
// updates the metadata/content caches, and dispatches to fs.watch listeners
|
||||
// so Obsidian's vault picks them up automatically.
|
||||
// Bridges WebSocket file events to the fs shim's metadata/content caches and fs.watch listeners.
|
||||
// The WebSocket itself is owned by ws-client.js; this module is a consumer.
|
||||
|
||||
import { isRecentLocalOp } from "./echo-guard.js";
|
||||
|
||||
const RECONNECT_DELAY = 2000;
|
||||
export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClient) {
|
||||
function handleCreated(msg) {
|
||||
const { path, stat } = msg;
|
||||
|
||||
export function createWatcherClient(metadataCache, contentCache, fsWatch) {
|
||||
let ws = null;
|
||||
let vaultId = null;
|
||||
let reconnectTimer = null;
|
||||
|
||||
function connect(vault) {
|
||||
vaultId = vault;
|
||||
|
||||
if (!vaultId) {
|
||||
console.warn("[watcher] No vault ID, skipping WebSocket connection");
|
||||
if (!path || isRecentLocalOp(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${protocol}//${window.location.host}/ws?vault=${encodeURIComponent(vaultId)}`;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
window.__ignisWs = ws;
|
||||
} catch (e) {
|
||||
console.error("[watcher] Failed to create WebSocket:", e);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
if (stat) {
|
||||
metadataCache.set(path, {
|
||||
type: "file",
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
ctime: stat.ctime,
|
||||
});
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("[watcher] Connected to file watcher");
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleEvent(msg);
|
||||
} catch (e) {
|
||||
console.error("[watcher] Failed to parse message:", e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log("[watcher] Disconnected");
|
||||
ws = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error("[watcher] WebSocket error:", e);
|
||||
};
|
||||
contentCache.invalidate(path);
|
||||
fsWatch._dispatch("created", path);
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
function handleFolderCreated(msg) {
|
||||
const { path } = msg;
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (!path || isRecentLocalOp(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (vaultId) {
|
||||
console.log("[watcher] Reconnecting...");
|
||||
connect(vaultId);
|
||||
}
|
||||
}, RECONNECT_DELAY);
|
||||
metadataCache.set(path, { type: "directory" });
|
||||
fsWatch._dispatch("folder-created", path);
|
||||
}
|
||||
|
||||
function handleEvent(msg) {
|
||||
// Skip channel-based plugin messages, those are for other listeners
|
||||
if (msg.channel) {
|
||||
function handleModified(msg) {
|
||||
const { path, stat } = msg;
|
||||
|
||||
if (!path || isRecentLocalOp(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, path, stat } = msg;
|
||||
if (stat) {
|
||||
metadataCache.set(path, {
|
||||
type: "file",
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
ctime: stat.ctime,
|
||||
});
|
||||
}
|
||||
|
||||
if (!type || !path) return;
|
||||
contentCache.invalidate(path);
|
||||
fsWatch._dispatch("modified", path);
|
||||
}
|
||||
|
||||
// Suppress echo from our own operations
|
||||
if (isRecentLocalOp(path)) {
|
||||
function handleDeleted(msg) {
|
||||
const { path } = msg;
|
||||
|
||||
if (!path || isRecentLocalOp(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "created":
|
||||
if (stat) {
|
||||
metadataCache.set(path, {
|
||||
type: "file",
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
ctime: stat.ctime,
|
||||
});
|
||||
}
|
||||
contentCache.invalidate(path);
|
||||
fsWatch._dispatch("created", path);
|
||||
break;
|
||||
metadataCache.delete(path);
|
||||
contentCache.invalidate(path);
|
||||
fsWatch._dispatch("deleted", path);
|
||||
}
|
||||
|
||||
case "folder-created":
|
||||
metadataCache.set(path, { type: "directory" });
|
||||
fsWatch._dispatch("folder-created", path);
|
||||
break;
|
||||
wsClient.subscribe("created", handleCreated);
|
||||
wsClient.subscribe("folder-created", handleFolderCreated);
|
||||
wsClient.subscribe("modified", handleModified);
|
||||
wsClient.subscribe("deleted", handleDeleted);
|
||||
|
||||
case "modified":
|
||||
if (stat) {
|
||||
metadataCache.set(path, {
|
||||
type: "file",
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
ctime: stat.ctime,
|
||||
});
|
||||
}
|
||||
contentCache.invalidate(path);
|
||||
fsWatch._dispatch("modified", path);
|
||||
break;
|
||||
|
||||
case "deleted":
|
||||
metadataCache.delete(path);
|
||||
contentCache.invalidate(path);
|
||||
fsWatch._dispatch("deleted", path);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn("[watcher] Unknown event type:", type);
|
||||
}
|
||||
function connect(vaultId) {
|
||||
wsClient.connect(vaultId);
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (ws) {
|
||||
ws.onclose = null; // prevent reconnect
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
wsClient.disconnect();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
28
packages/shim/src/ignis-api.js
Normal file
28
packages/shim/src/ignis-api.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Public Ignis API surface. The documented way for plugins (and Ignis-internal code) to reach shim services.
|
||||
// WIP, may expand to cover more shared functionality.
|
||||
|
||||
export function installIgnisApi(wsClient) {
|
||||
window.__ignis = window.__ignis || {};
|
||||
|
||||
// Live getters so vault info reflects whatever init.js / vault-switch code has set.
|
||||
Object.defineProperty(window.__ignis, "vault", {
|
||||
get() {
|
||||
return {
|
||||
id: window.__currentVaultId || null,
|
||||
path: window.__vaultConfig?.path || null,
|
||||
};
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
window.__ignis.ws = {
|
||||
subscribe: wsClient.subscribe,
|
||||
send: wsClient.send,
|
||||
channel: wsClient.channel,
|
||||
isOpen: wsClient.isOpen,
|
||||
onStateChange: wsClient.onStateChange,
|
||||
};
|
||||
|
||||
window.__ignis.plugins = window.__ignis.plugins || {};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fsShim } from "./fs/index.js";
|
||||
import { installRequestUrlShim } from "./request-url.js";
|
||||
import { vaultService } from "@ignis/services";
|
||||
import { showPluginInstallDialog } from "./ui-registry.js";
|
||||
import { registerReadTransform } from "./fs/transforms.js";
|
||||
import {
|
||||
resolveWorkspaceName,
|
||||
@@ -12,6 +11,12 @@ import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
||||
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
|
||||
import { initNativeMenuGuard } from "./native-menu-guard.js";
|
||||
|
||||
let bootstrapVirtualPlugins = [];
|
||||
|
||||
export function getBootstrapVirtualPlugins() {
|
||||
return bootstrapVirtualPlugins;
|
||||
}
|
||||
|
||||
function resolveVaultId() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
window.__currentVaultId =
|
||||
@@ -56,8 +61,6 @@ function applyVaultInfo(info) {
|
||||
path: "/",
|
||||
};
|
||||
|
||||
window.__ignisPlugin = info.ignisPlugin || null;
|
||||
|
||||
console.log("[ignis] Vault:", window.__vaultConfig);
|
||||
console.log("[ignis] Obsidian version:", window.__obsidianVersion);
|
||||
}
|
||||
@@ -124,30 +127,6 @@ function initMetadataCacheFallback() {
|
||||
}
|
||||
}
|
||||
|
||||
function initPluginPrompt() {
|
||||
if (
|
||||
!window.__ignisPlugin ||
|
||||
window.__ignisPlugin.installed ||
|
||||
window.__ignisPlugin.prompted
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultId = window.__currentVaultId;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.querySelector(".workspace")) {
|
||||
observer.disconnect();
|
||||
showPluginInstallDialog(vaultId);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// if headless sync is active, we transform reads of core-plugins.json to hide the sync setting from Obsidian.
|
||||
// this prevents headless sync from being disabled as a result of a different device syncing "Active core plugins list".
|
||||
// i.e ensure Ignis always has sync: false if headless sync is active.
|
||||
@@ -232,7 +211,7 @@ export function initialize() {
|
||||
autoTrustDemoVaults(bootstrap.vaultList);
|
||||
applyTree(bootstrap.tree);
|
||||
applyCoreSyncGuard(bootstrap.plugins);
|
||||
window.__ignisVirtualPlugins = bootstrap.virtualPlugins || [];
|
||||
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
|
||||
|
||||
// Race the indexer: batch-fetch text content into ContentCache so
|
||||
// Obsidian's startup indexing reads hit the cache instead of the network.
|
||||
@@ -250,5 +229,4 @@ export function initialize() {
|
||||
|
||||
installRequestUrlShim();
|
||||
initWorkspacePatch();
|
||||
initPluginPrompt();
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { installRequire } from "./require.js";
|
||||
import { installGlobals } from "./globals.js";
|
||||
import { installCssOverrides } from "./css-overrides.js";
|
||||
import { initialize } from "./init.js";
|
||||
import { initialize, getBootstrapVirtualPlugins } from "./init.js";
|
||||
import { fsShim } from "./fs/index.js";
|
||||
import { registerUI } from "./ui-registry.js";
|
||||
import {
|
||||
extractObsidianModule,
|
||||
loadVirtualPlugin,
|
||||
reportLoadFailure,
|
||||
watchPluginToggles,
|
||||
} from "./virtual-plugin-loader.js";
|
||||
import { wsClient } from "./ws-client.js";
|
||||
import { installIgnisApi } from "./ignis-api.js";
|
||||
|
||||
// __IGNIS_VERSION__ is replaced at build time from package.json.
|
||||
window.__ignis = { version: __IGNIS_VERSION__ };
|
||||
window.__ignis_registerUI = registerUI;
|
||||
|
||||
installIgnisApi(wsClient);
|
||||
|
||||
const BRIDGE_MANIFEST = {
|
||||
id: "ignis-bridge",
|
||||
name: "Ignis Bridge",
|
||||
@@ -38,9 +44,10 @@ if (window.innerWidth < 600) {
|
||||
|
||||
initialize(); // vault config, metadata cache, plugin prompt
|
||||
|
||||
// Connect file watcher WebSocket after everything is initialized
|
||||
// Connect the shared WebSocket after everything is initialized; watcher and live-toggle subscribers attach to the same client.
|
||||
if (window.__currentVaultId) {
|
||||
fsShim._watcherClient.connect(window.__currentVaultId);
|
||||
watchPluginToggles(wsClient);
|
||||
}
|
||||
|
||||
extractObsidianModule()
|
||||
@@ -52,12 +59,12 @@ extractObsidianModule()
|
||||
await bridge.onload();
|
||||
console.log("[ignis] bridge loaded");
|
||||
|
||||
for (const vp of window.__ignisVirtualPlugins || []) {
|
||||
for (const vp of getBootstrapVirtualPlugins()) {
|
||||
try {
|
||||
await loadVirtualPlugin(vp);
|
||||
console.log(`[ignis] virtual plugin loaded: ${vp.id}`);
|
||||
} catch (e) {
|
||||
console.error(`[ignis] virtual plugin load failed: ${vp.id}`, e);
|
||||
reportLoadFailure(vp.id, e);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,5 +22,4 @@ function proxy(name) {
|
||||
export const showVaultManager = proxy("showVaultManager");
|
||||
export const showMessageDialog = proxy("showMessageDialog");
|
||||
export const showConfirmDialog = proxy("showConfirmDialog");
|
||||
export const showPluginInstallDialog = proxy("showPluginInstallDialog");
|
||||
export const showPromptDialog = proxy("showPromptDialog");
|
||||
|
||||
@@ -43,8 +43,8 @@ function waitForApp() {
|
||||
}
|
||||
|
||||
export async function extractObsidianModule() {
|
||||
if (window.__obsidian) {
|
||||
return window.__obsidian;
|
||||
if (window.__ignis.obsidian) {
|
||||
return window.__ignis.obsidian;
|
||||
}
|
||||
|
||||
await waitForApp();
|
||||
@@ -97,48 +97,133 @@ export async function extractObsidianModule() {
|
||||
return null;
|
||||
}
|
||||
|
||||
window.__obsidian = captured;
|
||||
window.__ignis.obsidian = captured;
|
||||
registerShim("obsidian", captured);
|
||||
|
||||
console.log("[ignis] obsidian module captured");
|
||||
return captured;
|
||||
}
|
||||
|
||||
export async function loadVirtualPlugin(entry) {
|
||||
if (entry.cssUrl) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = entry.cssUrl;
|
||||
link.setAttribute("data-ignis-virtual-plugin", entry.id);
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
// Serialize per-id load/unload so rapid toggles can't race.
|
||||
const inFlight = new Map();
|
||||
|
||||
const res = await fetch(entry.scriptUrl);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`fetch ${entry.scriptUrl} -> ${res.status} ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const src =
|
||||
(await res.text()) + `\n//# sourceURL=ignis-virtual/${entry.id}.js`;
|
||||
|
||||
const module = { exports: {} };
|
||||
const localRequire = (name) =>
|
||||
name === "obsidian" ? window.__obsidian : window.require(name);
|
||||
|
||||
new Function("module", "exports", "require", src)(
|
||||
module,
|
||||
module.exports,
|
||||
localRequire,
|
||||
);
|
||||
|
||||
const PluginClass = module.exports.default || module.exports;
|
||||
const instance = new PluginClass(window.app, entry.manifest);
|
||||
|
||||
await instance.onload();
|
||||
|
||||
window.__ignis.plugins = window.__ignis.plugins || {};
|
||||
window.__ignis.plugins[entry.id] = { instance, manifest: entry.manifest };
|
||||
function serialized(id, fn) {
|
||||
const prev = inFlight.get(id) || Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
inFlight.set(id, next);
|
||||
next.finally(() => {
|
||||
if (inFlight.get(id) === next) {
|
||||
inFlight.delete(id);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function loadVirtualPlugin(entry) {
|
||||
return serialized(entry.id, async () => {
|
||||
window.__ignis.plugins = window.__ignis.plugins || {};
|
||||
|
||||
if (window.__ignis.plugins[entry.id]) {
|
||||
console.log(`[ignis] virtual plugin already loaded: ${entry.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.cssUrl) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = entry.cssUrl;
|
||||
link.setAttribute("data-ignis-virtual-plugin", entry.id);
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
const res = await fetch(entry.scriptUrl);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`fetch ${entry.scriptUrl} -> ${res.status} ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const src =
|
||||
(await res.text()) + `\n//# sourceURL=ignis-virtual/${entry.id}.js`;
|
||||
|
||||
const module = { exports: {} };
|
||||
const localRequire = (name) =>
|
||||
name === "obsidian" ? window.__ignis.obsidian : window.require(name);
|
||||
|
||||
new Function("module", "exports", "require", src)(
|
||||
module,
|
||||
module.exports,
|
||||
localRequire,
|
||||
);
|
||||
|
||||
const PluginClass = module.exports.default || module.exports;
|
||||
const instance = new PluginClass(window.app, entry.manifest);
|
||||
|
||||
// _loaded = true makes instance.unload() walk the Plugin's _register list later.
|
||||
// Cleans up addCommand / addStatusBarItem / addRibbonIcon / addSettingTab / registerEvent.
|
||||
instance._loaded = true;
|
||||
await instance.onload();
|
||||
|
||||
window.__ignis.plugins[entry.id] = { instance, manifest: entry.manifest };
|
||||
});
|
||||
}
|
||||
|
||||
export function unloadVirtualPlugin(id) {
|
||||
return serialized(id, async () => {
|
||||
const tracked = window.__ignis?.plugins?.[id];
|
||||
|
||||
if (!tracked) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await tracked.instance.unload();
|
||||
} catch (e) {
|
||||
reportUnloadFailure(id, e);
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll(`link[data-ignis-virtual-plugin="${id}"]`)
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
delete window.__ignis.plugins[id];
|
||||
});
|
||||
}
|
||||
|
||||
//TODO: move to ignis API object?
|
||||
function notice(text) {
|
||||
try {
|
||||
new window.__ignis.obsidian.Notice(text);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function reportLoadFailure(id, e) {
|
||||
console.error(`[ignis] virtual plugin load failed: ${id}`, e);
|
||||
notice(`Failed to load plugin '${id}': ${e.message}`);
|
||||
}
|
||||
|
||||
export function reportUnloadFailure(id, e) {
|
||||
console.warn(`[ignis] virtual plugin unload failed: ${id}`, e);
|
||||
notice(`Failed to unload plugin '${id}': ${e.message}`);
|
||||
}
|
||||
|
||||
export function watchPluginToggles(wsClient) {
|
||||
wsClient.subscribe("virtual-plugin-enable", (msg) => {
|
||||
if (msg.vault !== window.__currentVaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadVirtualPlugin(msg.entry).catch((e) =>
|
||||
reportLoadFailure(msg.entry?.id, e),
|
||||
);
|
||||
});
|
||||
|
||||
wsClient.subscribe("virtual-plugin-disable", (msg) => {
|
||||
if (msg.vault !== window.__currentVaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
unloadVirtualPlugin(msg.id).catch((e) => reportUnloadFailure(msg.id, e));
|
||||
});
|
||||
}
|
||||
|
||||
267
packages/shim/src/ws-client.js
Normal file
267
packages/shim/src/ws-client.js
Normal file
@@ -0,0 +1,267 @@
|
||||
// Vault-scoped WebSocket client.Single connection per shim instance.
|
||||
// Multiple consumers attach via subscribe/channel.
|
||||
|
||||
const RECONNECT_DELAY_MS = 2000;
|
||||
|
||||
export function createWsClient() {
|
||||
let ws = null;
|
||||
let vaultId = null;
|
||||
let reconnectTimer = null;
|
||||
let manuallyClosed = false;
|
||||
let state = "closed"; // "closed" | "connecting" | "open"
|
||||
|
||||
const globalSubs = new Map(); // type -> Set<handler>
|
||||
const channelSubs = new Map(); // channelName -> Map<type, Set<handler>>
|
||||
const channelSubCount = new Map(); // channelName -> integer
|
||||
const stateSubs = new Set(); // handler(state)
|
||||
|
||||
function setState(next) {
|
||||
if (state === next) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = next;
|
||||
|
||||
for (const fn of stateSubs) {
|
||||
try {
|
||||
fn(state);
|
||||
} catch (e) {
|
||||
console.error("[ws] state subscriber threw:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function postRaw(message) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
function sendSubscribeChannel(name) {
|
||||
postRaw({ type: "subscribe-channel", channel: name });
|
||||
}
|
||||
|
||||
function sendUnsubscribeChannel(name) {
|
||||
postRaw({ type: "unsubscribe-channel", channel: name });
|
||||
}
|
||||
|
||||
function dispatch(msg) {
|
||||
if (msg.channel) {
|
||||
const types = channelSubs.get(msg.channel);
|
||||
const handlers = types && types.get(msg.type);
|
||||
|
||||
if (handlers) {
|
||||
for (const fn of handlers) {
|
||||
try {
|
||||
fn(msg);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[ws] channel subscriber for ${msg.channel}:${msg.type} threw:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const handlers = globalSubs.get(msg.type);
|
||||
|
||||
if (handlers) {
|
||||
for (const fn of handlers) {
|
||||
try {
|
||||
fn(msg);
|
||||
} catch (e) {
|
||||
console.error(`[ws] subscriber for ${msg.type} threw:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSocket() {
|
||||
if (ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState("connecting");
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${protocol}//${window.location.host}/ws?vault=${encodeURIComponent(vaultId)}`;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch (e) {
|
||||
console.error("[ws] failed to create WebSocket:", e);
|
||||
ws = null;
|
||||
setState("closed");
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("[ws] connected");
|
||||
setState("open");
|
||||
|
||||
// Re-establish channel subscriptions on the new connection.
|
||||
for (const name of channelSubCount.keys()) {
|
||||
sendSubscribeChannel(name);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg;
|
||||
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch (e) {
|
||||
console.error("[ws] failed to parse message:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(msg);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
setState("closed");
|
||||
|
||||
if (!manuallyClosed) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error("[ws] error:", e);
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer || manuallyClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
console.log("[ws] reconnecting...");
|
||||
openSocket();
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}
|
||||
|
||||
function connect(id) {
|
||||
if (!id) {
|
||||
console.warn("[ws] no vault id; skipping connect");
|
||||
return;
|
||||
}
|
||||
|
||||
vaultId = id;
|
||||
manuallyClosed = false;
|
||||
openSocket();
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
manuallyClosed = true;
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
|
||||
setState("closed");
|
||||
}
|
||||
|
||||
function subscribe(type, handler) {
|
||||
if (!globalSubs.has(type)) {
|
||||
globalSubs.set(type, new Set());
|
||||
}
|
||||
|
||||
globalSubs.get(type).add(handler);
|
||||
|
||||
return () => {
|
||||
globalSubs.get(type)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
function send(type, payload) {
|
||||
postRaw({ type, ...(payload || {}) });
|
||||
}
|
||||
|
||||
function channel(name) {
|
||||
return {
|
||||
subscribe(type, handler) {
|
||||
if (!channelSubs.has(name)) {
|
||||
channelSubs.set(name, new Map());
|
||||
}
|
||||
|
||||
const types = channelSubs.get(name);
|
||||
|
||||
if (!types.has(type)) {
|
||||
types.set(type, new Set());
|
||||
}
|
||||
|
||||
types.get(type).add(handler);
|
||||
|
||||
// First subscriber for this channel: upgrade the server-side gate.
|
||||
const prevCount = channelSubCount.get(name) || 0;
|
||||
channelSubCount.set(name, prevCount + 1);
|
||||
|
||||
if (prevCount === 0) {
|
||||
sendSubscribeChannel(name);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const set = types.get(type);
|
||||
|
||||
if (!set || !set.has(handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
set.delete(handler);
|
||||
|
||||
const newCount = (channelSubCount.get(name) || 0) - 1;
|
||||
|
||||
if (newCount <= 0) {
|
||||
channelSubCount.delete(name);
|
||||
sendUnsubscribeChannel(name);
|
||||
} else {
|
||||
channelSubCount.set(name, newCount);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
send(type, payload) {
|
||||
postRaw({ channel: name, type, ...(payload || {}) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return state === "open";
|
||||
}
|
||||
|
||||
function onStateChange(handler) {
|
||||
stateSubs.add(handler);
|
||||
|
||||
return () => {
|
||||
stateSubs.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
subscribe,
|
||||
send,
|
||||
channel,
|
||||
isOpen,
|
||||
onStateChange,
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance. The shim has one WebSocket per page; consumers all share it.
|
||||
export const wsClient = createWsClient();
|
||||
Reference in New Issue
Block a user