diff --git a/package-lock.json b/package-lock.json index f57f264..9ce3dc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17858,6 +17858,27 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tsyringe": { "version": "4.10.0", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", diff --git a/src/documents/providers/habitatDocumentsProvider.test.ts b/src/documents/providers/habitatDocumentsProvider.test.ts index 0b9fe13..0a8a2fd 100644 --- a/src/documents/providers/habitatDocumentsProvider.test.ts +++ b/src/documents/providers/habitatDocumentsProvider.test.ts @@ -2,41 +2,34 @@ import * as Y from 'yjs'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { getYjsPayload } from '../../editors/yjs/yjsEditorBinding'; -import { __resetDocSessionRegistryForTests } from '../../habitat/docSession'; import { HabitatDocumentsProvider } from './habitatDocumentsProvider'; const URI = 'habitat://did:plc:rtzf5y356funa3tgp6fzmkjn/network.habitat.docs/test-rkey'; -vi.mock('../../habitat/docSession', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - acquireDocSession: vi.fn((uri: string) => { - const ydoc = new Y.Doc(); - return { +vi.mock('../../habitat/docSession', () => ({ + acquireDocSession: vi.fn((uri: string) => { + const ydoc = new Y.Doc(); + return { + uri, + ydoc, + provider: null, + node: null, + ownerRecord: { uri, - ydoc, - provider: null, - node: null, - ownerRecord: { - uri, - cid: 'bafy', - value: { name: 'Test doc', blob: null }, - }, - loadPromise: Promise.resolve(), - getTitle: () => 'Test doc', - resync: async () => undefined, - release: () => undefined, - }; - }), - }; -}); + cid: 'bafy', + value: { name: 'Test doc', blob: null }, + }, + loadPromise: Promise.resolve(), + getTitle: () => 'Test doc', + resync: async () => undefined, + release: () => undefined, + }; + }), +})); describe('HabitatDocumentsProvider', () => { afterEach(() => { - __resetDocSessionRegistryForTests(); vi.clearAllMocks(); }); diff --git a/src/habitat/docSession.test.ts b/src/habitat/docSession.test.ts index 3418e81..486d862 100644 --- a/src/habitat/docSession.test.ts +++ b/src/habitat/docSession.test.ts @@ -44,22 +44,21 @@ vi.mock('../auth/habitatAuth', () => ({ habitatAuth: { getAuthInfo: () => ({ did: 'did:plc:me' }) }, })); -import { - acquireDocSession, - __resetDocSessionRegistryForTests, -} from './docSession'; +import { DocSessionRegistry } from './docSession'; const URI = 'at://did:plc:alice/network.habitat.docs/3kvxyz'; +let registry: DocSessionRegistry; + beforeEach(() => { - __resetDocSessionRegistryForTests(); + registry = new DocSessionRegistry(); mocks.stop.mockClear(); mocks.providerDestroy.mockClear(); mocks.dial.mockClear(); }); afterEach(() => { - __resetDocSessionRegistryForTests(); + // No teardown needed — each test gets a fresh registry instance. }); async function flush() { @@ -71,16 +70,16 @@ async function flush() { describe('acquireDocSession', () => { it('returns the same ydoc instance for the same uri', () => { - const a = acquireDocSession(URI); - const b = acquireDocSession(URI); + const a = registry.acquire(URI); + const b = registry.acquire(URI); expect(a.ydoc).toBe(b.ydoc); a.release(); b.release(); }); it('acquire twice + release once keeps libp2p alive', async () => { - const a = acquireDocSession(URI); - const b = acquireDocSession(URI); + const a = registry.acquire(URI); + const b = registry.acquire(URI); await flush(); expect(mocks.stop).not.toHaveBeenCalled(); @@ -96,7 +95,7 @@ describe('acquireDocSession', () => { }); it('re-acquiring after teardown reuses the existing ydoc', async () => { - const a = acquireDocSession(URI); + const a = registry.acquire(URI); await flush(); const text = a.ydoc.getText('persistent'); text.insert(0, 'hello'); @@ -104,7 +103,7 @@ describe('acquireDocSession', () => { await flush(); expect(mocks.stop).toHaveBeenCalledTimes(1); - const b = acquireDocSession(URI); + const b = registry.acquire(URI); await flush(); expect(b.ydoc).toBe(a.ydoc); expect(b.ydoc.getText('persistent').toString()).toBe('hello'); @@ -114,8 +113,8 @@ describe('acquireDocSession', () => { }); it('release is idempotent on a single handle', async () => { - const a = acquireDocSession(URI); - const b = acquireDocSession(URI); + const a = registry.acquire(URI); + const b = registry.acquire(URI); await flush(); a.release(); diff --git a/src/habitat/docSession.ts b/src/habitat/docSession.ts index 1d6496a..be1e5a8 100644 --- a/src/habitat/docSession.ts +++ b/src/habitat/docSession.ts @@ -25,8 +25,8 @@ import type { HabitatDoc, TypedRecord } from './types'; * * Anti-flash trick: when refCount drops to 0 we tear down libp2p + the * connection provider, but keep the `Y.Doc` in the registry. The next - * `acquireDocSession(uri)` reuses the same in-memory state so re-opening a - * doc shows instantly, then refreshes from the PDS in the background. + * `acquire(uri)` reuses the same in-memory state so re-opening a doc shows + * instantly, then refreshes from the PDS in the background. */ export type DocSession = { @@ -75,344 +75,313 @@ type RegistryEntry = { lastEmittedTitle: string | null; }; -const ydocRegistry = new Map(); +const SAVE_DEBOUNCE_MS = 1000; -const entryTitleListeners = new Set<(uri: string, name: string) => void>(); +export class DocSessionRegistry { + private entries = new Map(); + private titleListeners = new Set<(uri: string, name: string) => void>(); + private visibilityListenerInstalled = false; -/** Subscribe to live title changes for open habitat docs (sidebar labels). */ -export function subscribeEntryTitleChanges( - listener: (uri: string, name: string) => void, -): () => void { - entryTitleListeners.add(listener); - return () => { - entryTitleListeners.delete(listener); - }; -} - -function emitEntryTitle(uri: string, name: string): void { - for (const listener of entryTitleListeners) { - listener(uri, name); + subscribeToTitleChanges( + listener: (uri: string, name: string) => void, + ): () => void { + this.titleListeners.add(listener); + return () => { + this.titleListeners.delete(listener); + }; } -} - -const SAVE_DEBOUNCE_MS = 1000; -/** - * Atomically swap in a new load promise and immediately attach a noop catch - * so it never surfaces as an unhandled rejection. Workspaces still observe - * load errors through `handle.loadPromise`; this just prevents the global - * "Uncaught (in promise)" overlay Vite/Electron pops up on dev when the - * registry-level promise has no .then() chained to it. - */ -function assignLoadPromise( - entry: RegistryEntry, - promise: Promise, -): void { - entry.loadPromise = promise; - promise.catch(() => undefined); -} + /** + * Acquire (or re-acquire) a session for `uri`. Always returns immediately + * with a `DocSession` whose `ydoc` is the canonical instance for `uri`; the + * first load resolves in the background and updates the ydoc in place. + */ + acquire(uri: string, options?: { initialYdoc?: Y.Doc }): DocSession { + this.ensureVisibilityListener(); + let entry = this.entries.get(uri); + if (!entry) { + const ydoc = options?.initialYdoc ?? new Y.Doc(); + entry = { + uri, + ydoc, + loadPromise: Promise.resolve({} as LoadDocResult), + ownerRecord: null, + node: null, + provider: null, + refCount: 0, + saveHandler: null, + saveTimer: null, + pendingSave: null, + teardownPromise: null, + titleSyncHandler: null, + lastEmittedTitle: null, + }; + this.entries.set(uri, entry); + this.attachTitleSyncHandler(entry); + this.assignLoadPromise(entry, this.initSession(entry)); + } else if (entry.teardownPromise || !entry.node) { + this.assignLoadPromise(entry, this.reinitSession(entry)); + } -let visibilityListenerInstalled = false; + entry.refCount += 1; + return this.makeHandle(entry); + } -/** - * Install one global `visibilitychange` listener that runs `resyncEntry` on - * every active entry whenever the page becomes visible. Mirrors upstream's - * per-route hook but at the registry level, so it Just Works for every - * workspace that opens a habitat doc. - */ -function ensureVisibilityListener() { - if (visibilityListenerInstalled) return; - if (typeof document === 'undefined') return; - visibilityListenerInstalled = true; - document.addEventListener('visibilitychange', () => { - if (document.visibilityState !== 'visible') return; - for (const entry of ydocRegistry.values()) { - // Skip torn-down entries — they'll resync next time someone acquires. - if (!entry.node) continue; - void resyncEntry(entry); + private emitTitle(uri: string, name: string): void { + for (const listener of this.titleListeners) { + listener(uri, name); } - }); -} + } -async function resyncEntry(entry: RegistryEntry) { - if (entry.ownerRecord) { - try { - await mergeAllEdits(entry.ownerRecord, entry.ydoc); - } catch (e) { - console.warn('[habitat] visibility resync merge failed', e); + private assignLoadPromise( + entry: RegistryEntry, + promise: Promise, + ): void { + entry.loadPromise = promise; + promise.catch(() => undefined); + } + + private ensureVisibilityListener(): void { + if (this.visibilityListenerInstalled) return; + if (typeof document === 'undefined') return; + this.visibilityListenerInstalled = true; + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') return; + for (const entry of this.entries.values()) { + if (!entry.node) continue; + void this.resyncEntry(entry); + } + }); + } + + private async resyncEntry(entry: RegistryEntry): Promise { + if (entry.ownerRecord) { + try { + await mergeAllEdits(entry.ownerRecord, entry.ydoc); + } catch (e) { + console.warn('[habitat] visibility resync merge failed', e); + } + } else { + try { + const result = await loadHabitatDoc(entry.uri, entry.ydoc); + entry.ownerRecord = result.ownerRecord; + } catch (e) { + console.warn('[habitat] visibility resync load failed', e); + } } - } else { - try { - const result = await loadHabitatDoc(entry.uri, entry.ydoc); - entry.ownerRecord = result.ownerRecord; - } catch (e) { - console.warn('[habitat] visibility resync load failed', e); + if (entry.node) { + await dialRelayAndStartPeerDiscovery(entry.uri, entry.node); } } - if (entry.node) { - await dialRelayAndStartPeerDiscovery(entry.uri, entry.node); - } -} -function scheduleSave(entry: RegistryEntry) { - if (entry.saveTimer) clearTimeout(entry.saveTimer); - entry.saveTimer = setTimeout(() => { - entry.saveTimer = null; - void runSave(entry); - }, SAVE_DEBOUNCE_MS); -} + private scheduleSave(entry: RegistryEntry): void { + if (entry.saveTimer) clearTimeout(entry.saveTimer); + entry.saveTimer = setTimeout(() => { + entry.saveTimer = null; + void this.runSave(entry); + }, SAVE_DEBOUNCE_MS); + } -async function runSave(entry: RegistryEntry) { - const myDid = habitatAuth.getAuthInfo()?.did; - if (!myDid || !entry.ownerRecord) return; - const ownerRecord = entry.ownerRecord; - entry.pendingSave = saveHabitatDoc({ - ownerRecord, - ydoc: entry.ydoc, - myDid, - }) - .then(() => { - const name = resolveDocDisplayTitle(entry.ydoc, ownerRecord.value.name); - entry.ownerRecord = { - ...ownerRecord, - value: { ...ownerRecord.value, name }, - }; - syncEntryTitle(entry, name); - }) - .catch((e) => { - console.error('[habitat] save failed', e); + private async runSave(entry: RegistryEntry): Promise { + const myDid = habitatAuth.getAuthInfo()?.did; + if (!myDid || !entry.ownerRecord) return; + const ownerRecord = entry.ownerRecord; + entry.pendingSave = saveHabitatDoc({ + ownerRecord, + ydoc: entry.ydoc, + myDid, }) - .finally(() => { - entry.pendingSave = null; - }); - await entry.pendingSave; -} - -function syncEntryTitle(entry: RegistryEntry, name?: string): void { - if (!entry.ownerRecord) return; - const next = - name ?? resolveDocDisplayTitle(entry.ydoc, entry.ownerRecord.value.name); - if (next === entry.lastEmittedTitle) return; - entry.lastEmittedTitle = next; - emitEntryTitle(entry.uri, next); -} - -function attachTitleSyncHandler(entry: RegistryEntry): void { - if (entry.titleSyncHandler) return; - entry.titleSyncHandler = () => syncEntryTitle(entry); - entry.ydoc.on('update', entry.titleSyncHandler); - syncEntryTitle(entry); -} + .then(() => { + const name = resolveDocDisplayTitle(entry.ydoc, ownerRecord.value.name); + entry.ownerRecord = { + ...ownerRecord, + value: { ...ownerRecord.value, name }, + }; + this.syncEntryTitle(entry, name); + }) + .catch((e) => { + console.error('[habitat] save failed', e); + }) + .finally(() => { + entry.pendingSave = null; + }); + await entry.pendingSave; + } -function detachTitleSyncHandler(entry: RegistryEntry): void { - if (!entry.titleSyncHandler) return; - entry.ydoc.off('update', entry.titleSyncHandler); - entry.titleSyncHandler = null; -} + private syncEntryTitle(entry: RegistryEntry, name?: string): void { + if (!entry.ownerRecord) return; + const next = + name ?? resolveDocDisplayTitle(entry.ydoc, entry.ownerRecord.value.name); + if (next === entry.lastEmittedTitle) return; + entry.lastEmittedTitle = next; + this.emitTitle(entry.uri, next); + } -function attachSaveHandler(entry: RegistryEntry) { - if (entry.saveHandler) return; - entry.saveHandler = (_update, origin) => { - // Skip remote-origin updates: those come from gossipsub and are already - // persisted by the peer who wrote them. Saving here would echo their - // edits back into our PDS unnecessarily. - if (origin === entry.provider) return; - scheduleSave(entry); - }; - entry.ydoc.on('updateV2', entry.saveHandler); -} + private attachTitleSyncHandler(entry: RegistryEntry): void { + if (entry.titleSyncHandler) return; + entry.titleSyncHandler = () => this.syncEntryTitle(entry); + entry.ydoc.on('update', entry.titleSyncHandler); + this.syncEntryTitle(entry); + } -function detachSaveHandler(entry: RegistryEntry) { - if (!entry.saveHandler) return; - entry.ydoc.off('updateV2', entry.saveHandler); - entry.saveHandler = null; -} + private detachTitleSyncHandler(entry: RegistryEntry): void { + if (!entry.titleSyncHandler) return; + entry.ydoc.off('update', entry.titleSyncHandler); + entry.titleSyncHandler = null; + } -/** - * Acquire (or re-acquire) a session for `uri`. Always returns immediately - * with a `DocSession` whose `ydoc` is the canonical instance for `uri`; the - * first load resolves in the background and updates the ydoc in place. - */ -export function acquireDocSession( - uri: string, - options?: { initialYdoc?: Y.Doc }, -): DocSession { - ensureVisibilityListener(); - let entry = ydocRegistry.get(uri); - if (!entry) { - const ydoc = options?.initialYdoc ?? new Y.Doc(); - entry = { - uri, - ydoc, - loadPromise: Promise.resolve({} as LoadDocResult), - ownerRecord: null, - node: null, - provider: null, - refCount: 0, - saveHandler: null, - saveTimer: null, - pendingSave: null, - teardownPromise: null, - titleSyncHandler: null, - lastEmittedTitle: null, + private attachSaveHandler(entry: RegistryEntry): void { + if (entry.saveHandler) return; + entry.saveHandler = (_update, origin) => { + if (origin === entry.provider) return; + this.scheduleSave(entry); }; - ydocRegistry.set(uri, entry); - attachTitleSyncHandler(entry); - // First-time init: load from PDS, then stand up libp2p. - assignLoadPromise(entry, initSession(entry)); - } else if (entry.teardownPromise || !entry.node) { - // Re-acquire after refcount hit zero — re-stand-up live editing while - // keeping the existing ydoc. Wait for any in-flight teardown to finish - // first so we don't race a `node.stop()` against the new node, then - // re-fetch in the background. - assignLoadPromise(entry, reinitSession(entry)); + entry.ydoc.on('updateV2', entry.saveHandler); } - entry.refCount += 1; - const captured = entry; - return makeHandle(captured); -} - -async function initSession(entry: RegistryEntry): Promise { - try { - const result = await loadHabitatDoc(entry.uri, entry.ydoc); - entry.ownerRecord = result.ownerRecord; - attachSaveHandler(entry); - syncEntryTitle(entry); - await ensureLiveEditing(entry); - return result; - } catch (e) { - console.error('[habitat] initial load failed for', entry.uri, e); - throw e; + private detachSaveHandler(entry: RegistryEntry): void { + if (!entry.saveHandler) return; + entry.ydoc.off('updateV2', entry.saveHandler); + entry.saveHandler = null; } -} -async function reinitSession(entry: RegistryEntry): Promise { - if (entry.teardownPromise) { + private async initSession(entry: RegistryEntry): Promise { try { - await entry.teardownPromise; - } catch { - // teardown errors are non-fatal; we'll proceed to re-init regardless + const result = await loadHabitatDoc(entry.uri, entry.ydoc); + entry.ownerRecord = result.ownerRecord; + this.attachSaveHandler(entry); + this.syncEntryTitle(entry); + await this.ensureLiveEditing(entry); + return result; + } catch (e) { + console.error('[habitat] initial load failed for', entry.uri, e); + throw e; } } - try { - const result = await loadHabitatDoc(entry.uri, entry.ydoc); - entry.ownerRecord = result.ownerRecord; - attachSaveHandler(entry); - syncEntryTitle(entry); - await ensureLiveEditing(entry); - return result; - } catch (e) { - console.error('[habitat] reload failed for', entry.uri, e); - throw e; - } -} -async function ensureLiveEditing(entry: RegistryEntry) { - if (entry.node) return; - try { - const node = await createHabitatLibp2pNode(); - entry.node = node; - entry.provider = new Libp2pConnectionProvider(node, entry.ydoc, entry.uri); - await dialRelayAndStartPeerDiscovery(entry.uri, node); - } catch (e) { - console.error( - '[habitat] could not start libp2p; doc is read-write but offline', - e, - ); - } -} - -function makeHandle(entry: RegistryEntry): DocSession { - let released = false; - // Capture the current loadPromise at handle-construction time. Re-acquires - // may swap entry.loadPromise out from under us; each handle should reflect - // the load that was in flight when *it* was created. - const loadPromise = entry.loadPromise.then(() => undefined); - const handle: DocSession = { - uri: entry.uri, - ydoc: entry.ydoc, - get provider() { - return entry.provider; - }, - get node() { - return entry.node; - }, - get ownerRecord() { - // ownerRecord may briefly be null between acquire and the first - // loadPromise resolution; callers that need it should await - // handle.loadPromise (or handle.resync()) first. - return entry.ownerRecord!; - }, - loadPromise, - getTitle() { - if (!entry.ownerRecord) return ''; - return resolveDocDisplayTitle(entry.ydoc, entry.ownerRecord.value.name); - }, - resync: () => resyncEntry(entry), - release: () => { - if (released) return; - released = true; - entry.refCount -= 1; - if (entry.refCount === 0) { - entry.teardownPromise = teardownEntry(entry).finally(() => { - entry.teardownPromise = null; - }); + private async reinitSession(entry: RegistryEntry): Promise { + if (entry.teardownPromise) { + try { + await entry.teardownPromise; + } catch { + // teardown errors are non-fatal } - }, - }; - return handle; -} - -async function teardownEntry(entry: RegistryEntry) { - if (entry.saveTimer) { - // Flush a pending save so the very last edit isn't dropped on close. - clearTimeout(entry.saveTimer); - entry.saveTimer = null; - await runSave(entry).catch(() => undefined); - } - if (entry.pendingSave) { - try { - await entry.pendingSave; - } catch { - // already logged in runSave } - } - if (entry.provider) { try { - entry.provider.destroy(); - } catch { - // best-effort + const result = await loadHabitatDoc(entry.uri, entry.ydoc); + entry.ownerRecord = result.ownerRecord; + this.attachSaveHandler(entry); + this.syncEntryTitle(entry); + await this.ensureLiveEditing(entry); + return result; + } catch (e) { + console.error('[habitat] reload failed for', entry.uri, e); + throw e; } - entry.provider = null; } - if (entry.node) { + + private async ensureLiveEditing(entry: RegistryEntry): Promise { + if (entry.node) return; try { - await entry.node.stop(); - } catch { - // best-effort + const node = await createHabitatLibp2pNode(); + entry.node = node; + entry.provider = new Libp2pConnectionProvider(node, entry.ydoc, entry.uri); + await dialRelayAndStartPeerDiscovery(entry.uri, node); + } catch (e) { + console.error( + '[habitat] could not start libp2p; doc is read-write but offline', + e, + ); } - entry.node = null; } - detachSaveHandler(entry); - // Intentionally keep `entry.ydoc` and `entry.ownerRecord` alive in the - // registry. The next `acquireDocSession(uri)` reuses them for a - // flash-free re-open and refreshes asynchronously. -} -/** Test-only: clear the registry between tests. */ -export function __resetDocSessionRegistryForTests() { - for (const entry of ydocRegistry.values()) { - if (entry.saveTimer) clearTimeout(entry.saveTimer); - detachTitleSyncHandler(entry); - try { - entry.provider?.destroy(); - } catch { - // ignore + private makeHandle(entry: RegistryEntry): DocSession { + let released = false; + const loadPromise = entry.loadPromise.then(() => undefined); + const handle: DocSession = { + uri: entry.uri, + ydoc: entry.ydoc, + get provider() { + return entry.provider; + }, + get node() { + return entry.node; + }, + get ownerRecord() { + return entry.ownerRecord!; + }, + loadPromise, + getTitle() { + if (!entry.ownerRecord) return ''; + return resolveDocDisplayTitle(entry.ydoc, entry.ownerRecord.value.name); + }, + resync: () => this.resyncEntry(entry), + release: () => { + if (released) return; + released = true; + entry.refCount -= 1; + if (entry.refCount === 0) { + entry.teardownPromise = this.teardownEntry(entry).finally(() => { + entry.teardownPromise = null; + }); + } + }, + }; + return handle; + } + + private async teardownEntry(entry: RegistryEntry): Promise { + if (entry.saveTimer) { + clearTimeout(entry.saveTimer); + entry.saveTimer = null; + await this.runSave(entry).catch(() => undefined); + } + if (entry.pendingSave) { + try { + await entry.pendingSave; + } catch { + // already logged in runSave + } + } + if (entry.provider) { + try { + entry.provider.destroy(); + } catch { + // best-effort + } + entry.provider = null; + } + if (entry.node) { + try { + await entry.node.stop(); + } catch { + // best-effort + } + entry.node = null; } - const stopped = entry.node?.stop(); - if (stopped) void stopped.catch(() => undefined); + this.detachSaveHandler(entry); + // Intentionally keep `entry.ydoc` and `entry.ownerRecord` alive in the + // registry. The next `acquire(uri)` reuses them for a flash-free re-open + // and refreshes asynchronously. } - ydocRegistry.clear(); - entryTitleListeners.clear(); +} + +// --------------------------------------------------------------------------- +// Default singleton — used by all production callers. +// Tests should instantiate DocSessionRegistry directly for isolation. +// --------------------------------------------------------------------------- + +const defaultRegistry = new DocSessionRegistry(); + +export function acquireDocSession( + uri: string, + options?: { initialYdoc?: Y.Doc }, +): DocSession { + return defaultRegistry.acquire(uri, options); +} + +export function subscribeEntryTitleChanges( + listener: (uri: string, name: string) => void, +): () => void { + return defaultRegistry.subscribeToTitleChanges(listener); } diff --git a/vitest.config.ts b/vitest.config.ts index 7eeb3f8..e1f31b6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,19 @@ export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'], + pool: 'forks', + forks: { + // @automerge/automerge ships a node-specific entry (fullfat_node.js, + // loads WASM from disk) and a browser entry (fullfat_base64.js, embeds + // ~3.5MB of WASM as base64). Vite picks browser by default and OOMs. + // --conditions=node tells Node.js to prefer the node condition when + // Vite externalises the package and falls back to native resolution. + execArgv: ['--conditions=node'], + }, + server: { + deps: { + external: [/@automerge/], + }, + }, }, });