diff --git a/package-lock.json b/package-lock.json index 0273e270..65d3111c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "corestore": "^6.8.4", "debug": "^4.3.4", "drizzle-orm": "0.28.2", + "eventemitter3": "^5.0.1", "fastify-plugin": "^4.5.0", "hypercore": "^10.9.0", "hypercore-crypto": "^3.3.1", @@ -41,6 +42,7 @@ "protobufjs": "^7.2.3", "protomux": "^3.4.1", "quickbit-universal": "^2.2.0", + "rpc-reflector": "^1.3.11", "sodium-universal": "^4.0.0", "start-stop-state-machine": "^1.2.0", "sub-encoder": "^2.1.1", @@ -948,6 +950,14 @@ "better-sqlite3": "^8.4.0" } }, + "node_modules/@msgpack/msgpack": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-1.12.2.tgz", + "integrity": "sha512-Vwhc3ObxmDZmA5hY8mfsau2rJ4vGPvzbj20QSZ2/E1GDPF61QVyjLfNHak9xmel6pW4heRt3v1fHa6np9Ehfeg==", + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -1967,6 +1977,11 @@ "version": "0.0.1", "license": "MIT" }, + "node_modules/const-max-uint32": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/const-max-uint32/-/const-max-uint32-1.0.2.tgz", + "integrity": "sha512-T8/9bffg5RThuejasJWrwqxs3Q0fsJvyl7/33IB6svroD8JC93E7X60AuuOnDE8RlP6Jlb5FxmlrVDpl9KiU2Q==" + }, "node_modules/convert-source-map": { "version": "1.9.0", "dev": true, @@ -2570,6 +2585,17 @@ } } }, + "node_modules/duplexify": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", + "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "license": "MIT" @@ -2962,6 +2988,11 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "node_modules/events": { "version": "3.3.0", "license": "MIT", @@ -4398,6 +4429,21 @@ "graceful-fs": "^4.1.11" } }, + "node_modules/length-prefixed-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/length-prefixed-stream/-/length-prefixed-stream-2.0.0.tgz", + "integrity": "sha512-dvjTuWTKWe0oEznQcG6a9osfiYknCs7DEFJMP88n9Y581IFhYh1sZIgAFcuDOojKB0G7ftPreKhh4D0kh/VPjQ==", + "dependencies": { + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "varint": "^5.0.0" + } + }, + "node_modules/length-prefixed-stream/node_modules/varint": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz", + "integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow==" + }, "node_modules/levn": { "version": "0.4.1", "dev": true, @@ -6327,6 +6373,43 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rpc-reflector": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/rpc-reflector/-/rpc-reflector-1.3.11.tgz", + "integrity": "sha512-TIf/RHJy11q/xmNBj0Xj2Z4GVPP1aLeaZXSRtthcMnXNuK+tv7SpZldB5Jk6RFHzA9TgxhcWvLHdHrdlEDKH0w==", + "dependencies": { + "@msgpack/msgpack": "^1.12.1", + "@types/node": "^18.16.19", + "duplexify": "^4.1.2", + "eventemitter3": "^5.0.1", + "is-stream": "^2.0.1", + "length-prefixed-stream": "^2.0.0", + "p-timeout": "^4.1.0", + "pump": "^3.0.0", + "serialize-error": "^8.1.0", + "through2": "^4.0.2", + "validate.io-array-like": "^1.0.2" + } + }, + "node_modules/rpc-reflector/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rpc-reflector/node_modules/p-timeout": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz", + "integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==", + "engines": { + "node": ">=10" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "dev": true, @@ -6472,6 +6555,31 @@ "dev": true, "license": "MIT" }, + "node_modules/serialize-error": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/set-cookie-parser": { "version": "2.6.0", "dev": true, @@ -6809,6 +6917,11 @@ "tiny-typed-emitter": "^2.1.0" } }, + "node_modules/stream-shift": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" + }, "node_modules/streamx": { "version": "2.15.1", "license": "MIT", @@ -7094,6 +7207,14 @@ "real-require": "^0.2.0" } }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -7480,6 +7601,28 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/validate.io-array-like": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/validate.io-array-like/-/validate.io-array-like-1.0.2.tgz", + "integrity": "sha512-rGLiN0cvY9OWzQcWP+RtqZR/MK9RUz3gKDTCcRLtEQ/BvlanMF5PyqtVIN+CgrIBCv/ypfme9v7r4yMJPYpbNA==", + "dependencies": { + "const-max-uint32": "^1.0.2", + "validate.io-integer-primitive": "^1.0.0" + } + }, + "node_modules/validate.io-integer-primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/validate.io-integer-primitive/-/validate.io-integer-primitive-1.0.0.tgz", + "integrity": "sha512-4ARGKA4FImVWJgrgttLYsYJmDGwxlhLfDCdq09gyVgohLKKRUfD3VAo1L2vTRCLt6hDhDtFKdZiuYUTWyBggwg==", + "dependencies": { + "validate.io-number-primitive": "^1.0.0" + } + }, + "node_modules/validate.io-number-primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/validate.io-number-primitive/-/validate.io-number-primitive-1.0.0.tgz", + "integrity": "sha512-8rlCe7N0TRTd50dwk4WNoMXNbX/4+RdtqE3TO6Bk0GJvAgbQlfL5DGr/Pl9ZLbWR6CutMjE2cu+yOoCnFWk+Qw==" + }, "node_modules/varint": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", diff --git a/package.json b/package.json index 6bb4bc38..15665a99 100644 --- a/package.json +++ b/package.json @@ -106,13 +106,14 @@ "b4a": "^1.6.3", "base32.js": "^0.1.0", "better-sqlite3": "^8.3.0", + "big-sparse-array": "^1.0.3", "bogon": "^1.1.0", "bonjour-service": "^1.1.1", - "big-sparse-array": "^1.0.3", "compact-encoding": "^2.12.0", "corestore": "^6.8.4", "debug": "^4.3.4", "drizzle-orm": "0.28.2", + "eventemitter3": "^5.0.1", "fastify-plugin": "^4.5.0", "hypercore": "^10.9.0", "hypercore-crypto": "^3.3.1", @@ -127,6 +128,7 @@ "protobufjs": "^7.2.3", "protomux": "^3.4.1", "quickbit-universal": "^2.2.0", + "rpc-reflector": "^1.3.11", "sodium-universal": "^4.0.0", "start-stop-state-machine": "^1.2.0", "sub-encoder": "^2.1.1", diff --git a/src/ipc-wrapper/client.js b/src/ipc-wrapper/client.js new file mode 100644 index 00000000..321ec21b --- /dev/null +++ b/src/ipc-wrapper/client.js @@ -0,0 +1,96 @@ +// @ts-check +import { createClient } from 'rpc-reflector' +import pDefer from 'p-defer' +import { MANAGER_CHANNEL_ID, MAPEO_RPC_ID, SubChannel } from './sub-channel.js' + +const CLOSE = Symbol('close') + +/** + * @param {import('./sub-channel.js').MessagePortLike} messagePort + * @returns {import('rpc-reflector/client.js').ClientApi} + */ +export function createMapeoClient(messagePort) { + /** @type {Map>>} */ + const projectClientPromises = new Map() + + const managerChannel = new SubChannel(messagePort, MANAGER_CHANNEL_ID) + const mapeoRpcChannel = new SubChannel(messagePort, MAPEO_RPC_ID) + + /** @type {import('rpc-reflector').ClientApi} */ + const managerClient = createClient(managerChannel) + /** @type {import('rpc-reflector').ClientApi} */ + const mapeoRpcClient = createClient(mapeoRpcChannel) + + mapeoRpcChannel.start() + managerChannel.start() + + const client = new Proxy(managerClient, { + get(target, prop, receiver) { + if (prop === CLOSE) { + return async () => { + managerChannel.close() + createClient.close(managerClient) + + const projectClientResults = await Promise.allSettled( + projectClientPromises.values() + ) + + for (const result of projectClientResults) { + if (result.status === 'fulfilled') { + createClient.close(result.value) + } + } + } + } + + if (prop === 'getProject') { + return createProjectClient + } + + return Reflect.get(target, prop, receiver) + }, + }) + + return client + + /** + * @param {import('../types.js').ProjectPublicId} projectPublicId + * @returns {Promise>} + */ + async function createProjectClient(projectPublicId) { + const existingClientPromise = projectClientPromises.get(projectPublicId) + + if (existingClientPromise) return existingClientPromise + + /** @type {import('p-defer').DeferredPromise>}*/ + const deferred = pDefer() + + projectClientPromises.set(projectPublicId, deferred.promise) + + try { + await mapeoRpcClient.assertProjectExists(projectPublicId) + } catch (err) { + deferred.reject(err) + throw err + } + + const projectChannel = new SubChannel(messagePort, projectPublicId) + + /** @type {import('rpc-reflector').ClientApi} */ + const projectClient = createClient(projectChannel) + projectChannel.start() + + deferred.resolve(projectClient) + + return projectClient + } +} + +/** + * @param {import('rpc-reflector').ClientApi} client client created with `createMapeoClient` + * @returns {Promise} + */ +export async function closeMapeoClient(client) { + // @ts-expect-error + return client[CLOSE]() +} diff --git a/src/ipc-wrapper/server.js b/src/ipc-wrapper/server.js new file mode 100644 index 00000000..f8181744 --- /dev/null +++ b/src/ipc-wrapper/server.js @@ -0,0 +1,114 @@ +// @ts-check +import { createServer } from 'rpc-reflector' +import { MANAGER_CHANNEL_ID, MAPEO_RPC_ID, SubChannel } from './sub-channel.js' +import { extractMessageEventData } from './utils.js' + +/** + * @param {import('../mapeo-manager.js').MapeoManager} manager + * @param {import('./sub-channel.js').MessagePortLike} messagePort + */ +export function createMapeoServer(manager, messagePort) { + /** @type {Map void }>} */ + const existingProjectServers = new Map() + + /** @type {Map} */ + const existingProjectChannels = new Map() + + const mapeoRpcApi = new MapeoRpcApi(manager) + + const managerChannel = new SubChannel(messagePort, MANAGER_CHANNEL_ID) + const mapeoRpcChannel = new SubChannel(messagePort, MAPEO_RPC_ID) + + const managerServer = createServer(manager, managerChannel) + const mapeoRpcServer = createServer(mapeoRpcApi, mapeoRpcChannel) + + managerChannel.start() + mapeoRpcChannel.start() + + messagePort.addEventListener('message', handleMessage) + + return { + close() { + messagePort.removeEventListener('message', handleMessage) + + for (const [id, server] of existingProjectServers.entries()) { + server.close() + + const channel = existingProjectChannels.get(id) + + if (channel) { + channel.close() + existingProjectChannels.delete(id) + } + + existingProjectServers.delete(id) + } + + managerServer.close() + managerChannel.close() + mapeoRpcServer.close() + mapeoRpcChannel.close() + }, + } + + /** + * @param {unknown} payload + */ + async function handleMessage(payload) { + const data = extractMessageEventData(payload) + + if (!data || typeof data !== 'object' || !('message' in data)) return + + const id = 'id' in data && typeof data.id === 'string' ? data.id : null + + if (!id || id === MANAGER_CHANNEL_ID || id === MAPEO_RPC_ID) return + + if (existingProjectChannels.has(id)) return + + const projectChannel = new SubChannel(messagePort, id) + existingProjectChannels.set(id, projectChannel) + + let project + try { + project = await manager.getProject( + /** @type {import('../types.js').ProjectPublicId} */ (id) + ) + } catch (err) { + // TODO: how to respond to client so that method errors? + projectChannel.close() + existingProjectChannels.delete(id) + existingProjectServers.delete(id) + return + } + + const { close } = createServer(project, projectChannel) + + existingProjectServers.set(id, { close }) + + projectChannel.emit('message', data.message) + + projectChannel.start() + } +} + +export class MapeoRpcApi { + #manager + + /** + * @param {import('../mapeo-manager.js').MapeoManager} manager + */ + constructor(manager) { + this.#manager = manager + } + + /** + * @param {string} projectId + * @returns {Promise} + */ + async assertProjectExists(projectId) { + const project = await this.#manager.getProject( + /** @type {import('../types.js').ProjectPublicId} */ (projectId) + ) + return !!project + } +} diff --git a/src/ipc-wrapper/sub-channel.js b/src/ipc-wrapper/sub-channel.js new file mode 100644 index 00000000..fc1845b5 --- /dev/null +++ b/src/ipc-wrapper/sub-channel.js @@ -0,0 +1,117 @@ +// @ts-check +import { EventEmitter } from 'eventemitter3' +import { extractMessageEventData } from './utils.js' + +// Ideally unique ID used for identifying "global" Mapeo IPC messages +export const MAPEO_RPC_ID = '@@mapeo-rpc' +export const MANAGER_CHANNEL_ID = '@@manager' + +/** + * @typedef {Object} Events + * @property {(message: any) => void} message + */ + +/** + * Node's built-in types for MessagePort are misleading so we opt for this limited type definition + * that fits our usage and works in both Node and browser contexts + * @typedef {EventTarget & { postMessage: (message: any) => void }} MessagePortLike + */ + +export class SubChannel extends EventEmitter { + #id + #messagePort + /** @type {'idle' | 'active' | 'closed'} */ + #state + /** @type {Array<{id: string, message: any}>} */ + #queued + #handleMessageEvent + + /** + * @param {MessagePortLike} messagePort Parent channel to add namespace to + * @param {string} id ID for the subchannel + */ + constructor(messagePort, id) { + super() + + this.#id = id + this.#messagePort = messagePort + this.#state = 'idle' + this.#queued = [] + + /** + * @param {unknown} event + */ + this.#handleMessageEvent = (event) => { + const value = extractMessageEventData(event) + + if (!isRelevantEvent(value)) return + + const { id, message } = value + + if (this.#id !== id) return + + switch (this.#state) { + case 'idle': { + this.#queued.push(value) + break + } + case 'active': { + this.emit('message', message) + break + } + case 'closed': { + // no-op if closed (the event listener should be removed anyway) + break + } + } + } + + this.#messagePort.addEventListener('message', this.#handleMessageEvent) + } + + get id() { + return this.#id + } + + /** + * Send messages with the subchannel's ID + * @param {any} message + */ + postMessage(message) { + this.#messagePort.postMessage({ id: this.#id, message }) + } + + start() { + if (this.#state !== 'idle') return + + this.#state = 'active' + + /** @type {{id: string, message: any} | undefined} */ + let event + while ((event = this.#queued.shift())) { + this.#handleMessageEvent(event) + } + } + + close() { + if (this.#state === 'closed') return + + this.#state = 'closed' + this.#queued = [] + + // Node types are incorrect (as of v14, Node's MessagePort should also extend [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget)) + this.#messagePort.removeEventListener('message', this.#handleMessageEvent) + } +} + +/** + * @param {unknown} event + * @returns {event is { id: string, message: any }} + */ +function isRelevantEvent(event) { + if (!event || typeof event !== 'object') return false + if (!('id' in event && 'message' in event)) return false + if (typeof event.id !== 'string') return false + + return true +} diff --git a/src/ipc-wrapper/utils.js b/src/ipc-wrapper/utils.js new file mode 100644 index 00000000..576bbb2f --- /dev/null +++ b/src/ipc-wrapper/utils.js @@ -0,0 +1,15 @@ +/** + * @template T + * @param {T} event + * @returns {T extends { data: infer D } ? D : T} + */ +export function extractMessageEventData(event) { + // In browser-like contexts, the actual payload will live in the `event.data` field + // https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/message_event#event_properties + if (event && typeof event === 'object' && 'data' in event) { + return /** @type {any} */ (event.data) + } + + // In Node the event is the actual data that was sent + return /** @type {any} */ (event) +} diff --git a/test-e2e/ipc-wrapper.js b/test-e2e/ipc-wrapper.js new file mode 100644 index 00000000..72ae1cc1 --- /dev/null +++ b/test-e2e/ipc-wrapper.js @@ -0,0 +1,170 @@ +import { test } from 'brittle' +import { MessageChannel } from 'node:worker_threads' +import RAM from 'random-access-memory' +import { KeyManager } from '@mapeo/crypto' +import { createMapeoServer } from '../src/ipc-wrapper/server.js' +import { MapeoManager } from '../src/mapeo-manager.js' +import { + createMapeoClient, + closeMapeoClient, +} from '../src/ipc-wrapper/client.js' + +test('IPC wrappers work', async (t) => { + const { client, cleanup } = setup() + + const projectId = await client.createProject({ name: 'mapeo' }) + + t.ok(projectId) + + const project = await client.getProject(projectId) + + t.ok(project) + + const projectSettings = await project.$getProjectSettings() + + t.alike(projectSettings, { name: 'mapeo', defaultPresets: undefined }) + + return cleanup() +}) + +test('Multiple projects and several calls in same tick', async (t) => { + const { client, cleanup } = setup() + + const sample = Array(10) + .fill(null) + .map((_, index) => { + return { + name: `Mapeo ${index}`, + defaultPresets: undefined, + } + }) + + const projectIds = await Promise.all( + sample.map(async (s) => client.createProject(s)) + ) + + const projects = await Promise.all( + projectIds.map((id) => client.getProject(id)) + ) + + const settings = await Promise.all( + projects.map((project) => project.$getProjectSettings()) + ) + + const listedProjects = await client.listProjects() + + t.is(projectIds.length, sample.length) + t.is(projects.length, sample.length) + t.is(settings.length, sample.length) + t.is(listedProjects.length, sample.length) + + settings.forEach((s, index) => { + const expectedSettings = sample[index] + t.alike(s, expectedSettings) + }) + + return cleanup() +}) + +test('Attempting to get non-existent project fails', async (t) => { + const { client, cleanup } = setup() + + await t.exception(async () => { + // @ts-expect-error + await client.getProject('mapeo') + }) + + const results = await Promise.allSettled([ + // @ts-expect-error + client.getProject('mapeo'), + // @ts-expect-error + client.getProject('mapeo'), + ]) + + t.alike( + results.map(({ status }) => status), + ['rejected', 'rejected'] + ) + + return cleanup() +}) + +test('Concurrent calls that succeed', async (t) => { + const { client, cleanup } = setup() + + const projectId = await client.createProject() + + const [project1, project2] = await Promise.all([ + client.getProject(projectId), + client.getProject(projectId), + ]) + + t.is(project1, project2) + + return cleanup() +}) + +test('Client calls fail after server closes', async (t) => { + const { client, server, cleanup } = setup() + + const projectId = await client.createProject({ name: 'mapeo' }) + const projectBefore = await client.getProject(projectId) + + await projectBefore.$getProjectSettings() + + server.close() + await closeMapeoClient(client) + + const projectAfter = await client.getProject(projectId) + + // Even after server closes we're still able to get the project ipc instance, which is okay + // because any field access should fail on that, rendering it unusable + // Adding this assertion to track changes in this behavior + t.ok(projectAfter) + + // Doing it this way to speed up the test because each should wait for a timeout + // Attempting to access any fields on the ipc instances should fail (aside from client.getProject, which is tested above) + const results = await Promise.allSettled([ + client.listProjects(), + projectBefore.$getProjectSettings(), + ]) + + for (const result of results) { + // @ts-ignore + t.is(result.status, 'rejected', result.reason) + } + + return cleanup() +}) + +function setup() { + const { port1, port2 } = new MessageChannel() + + const manager = new MapeoManager({ + rootKey: KeyManager.generateRootKey(), + dbFolder: ':memory:', + coreStorage: () => new RAM(), + }) + + // Since v14.7.0, Node's MessagePort extends EventTarget (https://nodejs.org/api/worker_threads.html#class-messageport) + // @ts-expect-error + const server = createMapeoServer(manager, port1) + // @ts-expect-error + const client = createMapeoClient(port2) + + port1.start() + port2.start() + + return { + port1, + port2, + server, + client, + cleanup: async () => { + server.close() + await closeMapeoClient(client) + port1.close() + port2.close() + }, + } +}