Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,20 @@ The wrappers never close or destroy the `messagePort` itself — that is the cal

`client.getProject(id)` resolves with a client that reflects the `MapeoProject` API, including nested namespaces such as `project.observation.*`.

- **Deduplicated.** Concurrent or repeated `getProject(id)` calls for an open project resolve to the same reference and open the project only once on the server.
- **Deduplicated.** Concurrent or repeated `getProject(id)` calls resolve to the same reference — project references are permanent for the lifetime of the client.
- **Missing projects.** If the project does not exist, `getProject(id)` rejects with `NotFoundError` (from `@comapeo/core`). A failed lookup is not cached, so a later call for an id that does exist still succeeds.
- **Isolation.** Closing one project does not affect other open projects.
- **Left projects.** If this device has left the project (`manager.leaveProject`), `getProject(id)` — and every method call on an already-held reference — rejects with [`ProjectLeftError`](#errors) until the project is re-joined via an invite, after which the same reference works again.

### Lifecycle

- `project.close()` closes the project on the server and tears down its channel. It is idempotent — repeated calls resolve like the first.
- After a project is closed — via `project.close()` **or** by the server closing it — every method on that reference rejects with [`ProjectClosedError`](#errors).
- A project can be re-opened: after closing, `getProject(id)` opens a fresh instance and returns a new reference. Calls on the old, closed reference never reach the re-opened project — they keep rejecting.
- `closeComapeoCoreClient(client)` tears down the manager, the project-routing channel, and every open project reference. After this, all calls — including `getProject(id)` — reject with [`ClientClosedError`](#errors). (The services client is independent; close it separately with [`closeComapeoServicesClient`](#closecomapeoservicesclientservicesclient-clientapicomapeoservicesapi-void).)
- Calls already in flight when a close happens reject with [`RpcChannelClosedError`](#errors); they are not re-routed.
Project instance lifecycle is owned entirely by the server. The client cannot close a project (the reflected surface has no `project.close()`), and a project reference never goes stale:

- The server may close a project instance at any time (resource management, `addProject` re-joining a previously-left project, a server restart). The next call on that project's channel transparently re-opens it — callers never observe the cycle.
- Event subscriptions survive server-side close/re-open: the server records each project's subscribed events and replays them into the fresh instance before serving buffered calls.
- The one exception is a left project, which is never re-opened — see [`ProjectLeftError`](#errors).
- A `leaveProject` call routed through this server also closes the stale instance `@comapeo/core` leaves cached after leaving (core only cleans that up itself inside `addProject`).
- `closeComapeoCoreClient(client)` tears down the manager, the project-routing channel, and every project reference. After this, all calls — including `getProject(id)` — reject with [`ClientClosedError`](#errors). (The services client is independent; close it separately with [`closeComapeoServicesClient`](#closecomapeoservicesclientservicesclient-clientapicomapeoservicesapi-void).)
- Calls already in flight when the client closes reject with [`RpcChannelClosedError`](#errors); they are not re-routed.

### Events

Expand All @@ -98,19 +101,17 @@ Error classes are available from the `@comapeo/ipc/errors.js` entrypoint:

```ts
import {
ProjectClosedError,
ProjectLeftError,
ClientClosedError,
RpcChannelClosedError,
RpcTimeoutError,
} from '@comapeo/ipc/errors.js'
```

After a reference is closed, calls made on it reject with a descriptive error:

- **`ProjectClosedError`** (`code: 'PROJECT_CLOSED'`) — a method (including nested namespaces such as `project.observation.*`) was called on a project reference after that project was closed, either via `await project.close()` or by the server closing the project. A re-opened reference from a fresh `client.getProject(id)` is unaffected.
- **`ProjectLeftError`** (`code: 'PROJECT_LEFT'`) — a method (including nested namespaces such as `project.observation.*`) or `getProject(id)` was called for a project this device has left. Left projects are never transparently re-opened; re-joining via an invite makes the same reference usable again.
- **`ClientClosedError`** (`code: 'CLIENT_CLOSED'`) — a method was called on the CoMapeo client, or on any project reference, after the whole client was torn down with [`closeComapeoCoreClient`](#closecomapeocoreclientclient-clientapimapeomanager-promisevoid). This includes `getProject(id)`, which after close rejects with `ClientClosedError` rather than returning a reference — whether or not that project was fetched earlier.

RPC methods return a rejected `Promise` carrying the error, so failures surface through normal `await`/`.catch()` handling. The exception is the event-emitter methods (`on`, `once`, `off`, `removeListener`, `emit`, etc.), which return synchronously rather than a promise — after close these **throw** the same error synchronously instead, so it surfaces at the call site rather than as an unhandled rejection.
RPC methods return a rejected `Promise` carrying the error, so failures surface through normal `await`/`.catch()` handling. The exception is the event-emitter methods, which return synchronously rather than a promise — after the client is closed, subscribe methods (`on`, `once`, `addListener`, and `emit`/introspection) **throw** `ClientClosedError` synchronously so the failure surfaces at the call site rather than as an unhandled rejection, while unsubscribe methods (`off`, `removeListener`, `removeAllListeners`) are safe no-ops — removing a listener from a dead client is correct teardown.

Calls that were already in flight when the close happened are not re-routed: they reject with **`RpcChannelClosedError`** as the underlying channel tears down. `RpcTimeoutError` is thrown when a call exceeds the `opts.timeout` passed to [`createComapeoCoreClient`](#createcomapeocoreclientmessageport-messageportlike-opts--timeout-number--clientapimapeomanager).

Expand Down
180 changes: 77 additions & 103 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { createClient } from 'rpc-reflector/client.js'

import {
MANAGER_CHANNEL_ID,
PROJECT_CHANNEL_PREFIX,
PROJECT_ROUTING_ID,
SERVICES_ID,
SubChannel,
} from './lib/sub-channel.js'
import { ClientClosedError, ProjectClosedError } from './errors.js'
import { ClientClosedError } from './errors.js'

/** @import { ClientApi, MessagePortLike } from 'rpc-reflector' */
/** @import { MapeoProject, MapeoManager } from '@comapeo/core' */
Expand All @@ -16,36 +17,45 @@ import { ClientClosedError, ProjectClosedError } from './errors.js'
// synchronously (they return the client/an array/a number, never a promise).
// Mirrors the method set rpc-reflector treats specially (`prop in
// EventEmitter.prototype`).
const EMITTER_METHODS = new Set([
'addListener',
'on',
'once',
const SUBSCRIBE_METHODS = new Set(['addListener', 'on', 'once'])
const UNSUBSCRIBE_METHODS = new Set([
'removeListener',
'off',
'removeAllListeners',
])
const OTHER_EMITTER_METHODS = new Set([
'emit',
'eventNames',
'listeners',
'listenerCount',
])

/**
* Build the Proxy returned for a closed client/project reference. Method calls
* (including nested namespaces such as `project.observation.*`) reject with
* `makeError()`, keeping the `Promise`-returning contract callers expect.
* EventEmitter methods are the exception: callers don't await them, so a
* rejected promise would surface as an unhandled rejection — they throw
* synchronously instead, at the call site.
* Build the Proxy returned for a reference after the whole client is closed.
* Method calls (including nested namespaces such as `project.observation.*`)
* reject with `makeError()`, keeping the `Promise`-returning contract callers
* expect. EventEmitter methods are the exception: callers don't await them,
* so a rejected promise would surface as an unhandled rejection. Subscribe
* methods throw synchronously at the call site; unsubscribe methods are
* chainable no-ops — removing a listener from a dead client is correct
* teardown (React effect cleanup runs against stale references).
*
* @param {() => Error} makeError
*/
function createClosedProxy(makeError) {
/** @type {any} */
let proxy
/** @type {ProxyHandler<any>} */
const handler = {
get(_target, prop) {
if (typeof prop === 'string' && EMITTER_METHODS.has(prop)) {
return () => {
throw makeError()
if (typeof prop === 'string') {
if (SUBSCRIBE_METHODS.has(prop) || OTHER_EMITTER_METHODS.has(prop)) {
return () => {
throw makeError()
}
}
if (UNSUBSCRIBE_METHODS.has(prop)) {
return () => proxy
}
}
return new Proxy(function () {}, handler)
Expand All @@ -57,11 +67,12 @@ function createClosedProxy(makeError) {
return Promise.reject(makeError())
},
}
return new Proxy({}, handler)
proxy = new Proxy({}, handler)
return proxy
}

/**
* @typedef {ClientApi<MapeoProject>} ComapeoProjectClientApi
* @typedef {Omit<ClientApi<MapeoProject>, 'close'>} ComapeoProjectClientApi
*/

/**
Expand All @@ -77,34 +88,39 @@ function createClosedProxy(makeError) {
const CLOSE = Symbol('close')

/**
* Create the client side of `createComapeoCoreServer`.
*
* Project references returned by `getProject` are permanent: each is bound to
* a channel keyed by the project's public id, which the server keeps valid
* across instance close/re-open cycles (and across server restarts). There is
* no client-visible project lifecycle — no `close()`, and no reference ever
* goes stale. Calls to a project this device has left reject with
* `ProjectLeftError` (server-side); calls to an unknown project reject with
* core's `NotFoundError`.
*
* @param {MessagePortLike} messagePort
* @param {Parameters<typeof createClient>[1]} [opts]
*
* @returns {ComapeoCoreClientApi}
*/
export function createComapeoCoreClient(messagePort, opts = {}) {
/**
* projectPublicId → wrapper bound to a specific instance id. Only returned
* after the server confirms that instance is still current — the server
* can close a project without the client asking (e.g. `leaveProject`).
* @type {Map<string, {
* instanceId: string,
* wrapper: ClientApi<MapeoProject>,
* }>}
* projectPublicId → permanent wrapper. Never evicted: the channel id is
* stable, so the wrapper stays valid for the lifetime of this client.
* @type {Map<string, ComapeoProjectClientApi>}
*/
const currentProjectClients = new Map()
const projectClients = new Map()

/**
* projectPublicId → in-flight `getProject`. Dedupes concurrent calls;
* entries are removed on settle so later calls re-validate.
* @type {Map<string, Promise<ClientApi<MapeoProject>>>}
* @type {Map<string, Promise<ComapeoProjectClientApi>>}
*/
const pendingProjectClients = new Map()

/**
* The rpc-reflector client + SubChannel pair for every currently-open
* project. Entries are removed when the project's wrapped `close()`
* settles; `closeComapeoCoreClient` sweeps whatever is left.
* The rpc-reflector client + SubChannel pair for every project wrapper,
* swept by `closeComapeoCoreClient`.
* @type {Set<{
* client: ClientApi<MapeoProject>,
* channel: SubChannel,
Expand All @@ -123,11 +139,11 @@ export function createComapeoCoreClient(messagePort, opts = {}) {
projectRoutingChannel.start()
managerChannel.start()

// Set once `closeComapeoCoreClient` has torn the whole client down. Read by the
// manager proxy and the per-project wrappers so that calls after close
// surface `ManagerClosedError` instead of rpc-reflector's `ChannelClosed`.
// Set once `closeComapeoCoreClient` has torn the whole client down. Read by
// the manager proxy and the per-project wrappers so that calls after close
// surface `ClientClosedError` instead of rpc-reflector's `ChannelClosed`.
let clientClosed = false
const managerClosedProxy = createClosedProxy(() => new ClientClosedError())
const clientClosedProxy = createClosedProxy(() => new ClientClosedError())

const client = new Proxy(managerClient, {
get(target, prop, receiver) {
Expand All @@ -147,6 +163,7 @@ export function createComapeoCoreClient(messagePort, opts = {}) {
entry.channel.close()
}
openProjectClients.clear()
projectClients.clear()

// Closed last so in-flight `assertProjectExists` calls awaited
// above can complete rather than reject.
Expand All @@ -158,13 +175,13 @@ export function createComapeoCoreClient(messagePort, opts = {}) {
}

if (prop === 'getProject') {
return createProjectClient
return getProject
}

// `then` must stay falsy so awaiting the client (a thenable check) does
// not route into the throwing proxy.
if (clientClosed && prop !== 'then') {
return Reflect.get(managerClosedProxy, prop)
return Reflect.get(clientClosedProxy, prop)
}

return Reflect.get(target, prop, receiver)
Expand All @@ -178,7 +195,7 @@ export function createComapeoCoreClient(messagePort, opts = {}) {
* @param {string} projectPublicId
* @returns {Promise<ComapeoProjectClientApi>}
*/
async function createProjectClient(projectPublicId) {
async function getProject(projectPublicId) {
// Checked before the cache lookup so `getProject` rejects uniformly after
// close — whether or not this id was fetched (and cached) earlier.
if (clientClosed) throw new ClientClosedError()
Expand All @@ -196,96 +213,53 @@ export function createComapeoCoreClient(messagePort, opts = {}) {
}

/**
* Return the cached wrapper only if the server confirms its instance id is
* still current; otherwise build a fresh one. The server evicts its routing
* entry synchronously on close, so this is correct even before the close
* event reaches this client.
*
* @param {string} projectPublicId
* @returns {Promise<ClientApi<MapeoProject>>}
* @returns {Promise<ComapeoProjectClientApi>}
*/
async function resolveProjectClient(projectPublicId) {
const instanceId =
await projectRoutingClient.assertProjectExists(projectPublicId)
// One round trip on every `getProject`, so a bad id rejects here (with
// `NotFoundError` / `ProjectLeftError`) rather than on the first method
// call, and so the server opens the project eagerly. The returned
// wrapper is the same object across calls.
await projectRoutingClient.assertProjectExists(projectPublicId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this anymore?


const current = currentProjectClients.get(projectPublicId)
if (current && current.instanceId === instanceId) return current.wrapper
const existing = projectClients.get(projectPublicId)
if (existing) return existing

const wrapper = createProjectClientWrapper(projectPublicId, instanceId)
currentProjectClients.set(projectPublicId, { instanceId, wrapper })
const wrapper = createProjectClientWrapper(projectPublicId)
projectClients.set(projectPublicId, wrapper)
return wrapper
}

/**
* @param {string} projectPublicId
* @param {string} instanceId
* @returns {ClientApi<MapeoProject>}
* @returns {ComapeoProjectClientApi}
*/
function createProjectClientWrapper(projectPublicId, instanceId) {
// Per-project messages are scoped to the current open instance, not the
// project's public id. If this project is closed and re-opened later,
// `assertProjectExists` returns a different instance id, so the new
// wrapper uses a fresh SubChannel that can't collide with the old one.
const projectChannel = new SubChannel(messagePort, instanceId)
function createProjectClientWrapper(projectPublicId) {
const projectChannel = new SubChannel(
messagePort,
`${PROJECT_CHANNEL_PREFIX}${projectPublicId}`,
)

/** @type {ClientApi<MapeoProject>} */
const projectClient = createClient(projectChannel, opts)
projectChannel.start()

const registryEntry = { client: projectClient, channel: projectChannel }
openProjectClients.add(registryEntry)

// Wrap projectClient to intercept `close`: after the wire close settles,
// tear down the local client + channel — rejecting any in-flight calls.
// Cache eviction is in the 'close' listener below, which also covers
// manager-initiated closes.
// Further method calls on this wrapper reject with `ProjectClosedError`.
// The close promise is cached so repeated `close()` calls return the
// same result instead of failing on the already-closed channel. All
// other property accesses delegate to the inner client unchanged.
/** @type {Promise<void> | null} */
let closePromise = null
let closed = false
// After this reference is closed, any method (including nested namespaces)
// throws a descriptive error rather than rpc-reflector's `ChannelClosed`:
// `ProjectClosedError` when this project was closed, `ManagerClosedError`
// when the whole client was torn down. In-flight calls at close time are
// left to reject with `ChannelClosed` — they were already on the wire.
const closedProxy = createClosedProxy(() =>
closed ? new ProjectClosedError() : new ClientClosedError(),
)
openProjectClients.add({ client: projectClient, channel: projectChannel })

const wrappedProjectClient = new Proxy(projectClient, {
get(target, prop, receiver) {
if (prop === 'close') {
return () => {
closePromise ??= (async () => {
try {
await target.close()
} finally {
createClient.close(projectClient)
projectChannel.close()
}
})()
return closePromise
}
}
if ((closed || clientClosed) && prop !== 'then') {
return Reflect.get(closedProxy, prop)
// Project lifecycle is server-owned: the reflected surface must not
// expose `MapeoProject.close`, which would close the server-side
// instance out from under every other consumer.
if (prop === 'close') return undefined
if (clientClosed && prop !== 'then') {
return Reflect.get(clientClosedProxy, prop)
}
return Reflect.get(target, prop, receiver)
},
})
wrappedProjectClient.once('close', () => {
closed = true
// A late close event must not evict a newer wrapper cached for the
// re-opened instance.
const current = currentProjectClients.get(projectPublicId)
if (current?.wrapper === wrappedProjectClient) {
currentProjectClients.delete(projectPublicId)
}
openProjectClients.delete(registryEntry)
})
return wrappedProjectClient
return /** @type {any} */ (wrappedProjectClient)
}
}

Expand Down
13 changes: 7 additions & 6 deletions src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ export {
} from 'rpc-reflector/errors.js'

/**
* Thrown server-side when a stale call reaches a project instance that has
* already been closed. Rides the standard rpc-reflector error response back
* to the client.
* Thrown server-side when a call arrives for a project this device has left
* (`manager.leaveProject`). Left projects are never re-opened by the server;
* re-joining via an invite (`manager.addProject`) makes the project usable
* again. Rides the standard rpc-reflector error response back to the client.
*/
export const ProjectClosedError = createErrorClass({
code: 'PROJECT_CLOSED',
message: 'Project is closed',
export const ProjectLeftError = createErrorClass({
code: 'PROJECT_LEFT',
message: 'This device has left the project',
status: 410,
})

Expand Down
Loading