Skip to content
3 min

Middleware and Events

Extend the Transport, Endpoint, Service, and collaboration extension modules with general-purpose Middleware and Events.

Middleware and Events are general-purpose extension points in the Collaboration SDK. Identity and authorization are integrated through Middleware. Use Middleware for validation and restrictions before an operation runs, and Events to record changes or trigger follow-up work after state is confirmed. Both can support logging, request tracing, metrics, and derived-state updates.

Two extension moments

MiddlewareEvent
RunsDuring an operationAfter a state change is confirmed
Can participate in or reject the operationYesNo
Can enrich call ContextYesReads Context and result carried by the Event
Listener failureMay fail the current operationIs isolated from the already-determined domain result
Typical usesIdentity, authorization, validation, logging, timingDerived indexes, caches, metrics, in-process notifications

Use Middleware when you must decide whether an operation can continue. Use an Event when you need to observe that a state change has already happened.

Middleware execution model

Middleware for the same Action forms an asynchronous chain in registration order. await next() enters the next Middleware; the SDK operation runs at the innermost point:

TypeScript
service.use("submitChangeset", async (context, next) => {
  const startedAt = performance.now();
  context.customData.startedAt = startedAt;

  try {
    await next();
  } finally {
    metrics.observe("collaboration.submit.duration", performance.now() - startedAt);
  }
});

Middleware can:

  • read the Action-specific request;
  • share call-local data through customData;
  • inspect input or query application systems before next();
  • record duration or results after next() returns;
  • throw a CollabError to reject an expected collaboration operation.

Transport HTTP Middleware may end the HTTP response without calling next(). Service and Endpoint Middleware should reject with an explicit error rather than silently skipping next().

Transport Middleware

The Transport has separate Middleware chains for ordinary HTTP requests and WebSocket upgrades:

TypeScript
transport.use(async (context, next) => {
  context.customData.traceID = readTraceID(context.incomingMessage);
  requestLogger.info("collaboration request", {
    method: context.incomingMessage.method,
    traceID: context.customData.traceID,
  });
  await next();
});

transport.useUpgrade(async (context, next) => {
  if (!isAllowedOrigin(context.incomingMessage.headers.origin)) {
    context.reject(403, "Origin rejected");
    return;
  }
  await next();
});

transport.use() participates only in ordinary HTTP requests. It is suitable for identity, request logging, tracing, CORS, and request-local customData. transport.useUpgrade() participates only in the WebSocket handshake and can reject it before connection.

Endpoint Middleware

Endpoint Middleware participates in realtime Session and Presence operations:

ActionRunsCommon uses
connectAfter ticket consumption and Session creation, before HELLOConnection policy, member display name and avatar
joinUnitBefore a Session joins a Unit RoomRoom admission and Unit visibility
receivePresenceAfter Presence arrives from a joined Session, before broadcastValidation and Presence filtering
sendPresenceBefore Presence is sent to each Room memberPer-recipient Presence filtering
TypeScript
endpoint.use("receivePresence", async (context, next) => {
  if (JSON.stringify(context.payload).length > 8_000) {
    throw new CollabError("INVALID_REQUEST", "Presence payload is too large");
  }
  await next();
});

The Endpoint's session.userID and session.customData come from the HTTP request that issued the Session Ticket. The Endpoint creates memberID for the current WebSocket Session, and it changes after reconnection.

Service Middleware

Service Middleware participates in authoritative collaboration data lifecycles:

ActionBoundaryCommon uses
readUnitDataBefore reading snapshots, blocks, or changesetsRead policy, tenant checks, audit
createUnitBefore atomically creating a UnitType checks, create policy, business rules
deleteUnitsBefore writing a delete batchDelete policy and per-Unit checks
recoverUnitsBefore writing a recovery batchRecovery policy and audit
submitChangesetBefore logical submission enters idempotency and OTEdit policy, total size limits, trace
applyChangesetBefore applying the transformed changeset to candidate stateMutation-level validation
commitChangesetBefore Adapter revision CAS and commitFinal revision rules and metrics
TypeScript
service.use("createUnit", async (context, next) => {
  await creationPolicy.assertAllowed({
    userID: context.userID,
    unitID: context.request.snapshot.unitID,
    type: context.request.snapshot.type,
  });
  context.customData.creationPolicyChecked = true;
  await next();
});

submitChangeset runs once for each logical submission. Revision contention may cause applyChangeset and commitChangeset to run again with a new attempt. Middleware at those stages must be retry-safe and must not directly send irreversible external messages or perform one-time charges.

customData scope

customData is an application-writable, call-local object suitable for:

  • tenant IDs, trace IDs, or request loggers;
  • reused ACL lookups for the current call;
  • timing starts or audit labels;
  • business objects shared by one Middleware chain.

Ordinary HTTP requests, Service calls, and WebSocket Sessions have different lifecycles. customData is not automatically persisted, sent to the browser, or logged. Long-lived data still belongs in application storage.

Event execution model

Services register process-local listeners with on(), which returns a disposable subscription:

TypeScript
const subscription = service.on("changesetCommitted", async (event) => {
  metrics.increment("collaboration.changeset.committed", {
    unitType: String(event.changeset.type),
  });
  localCache.delete(event.changeset.unitID);
});

subscription.dispose();

Listeners for the same Event run sequentially in registration order and are awaited. Listener errors are logged and isolated from the already-determined domain result. A failed changesetCommitted listener, for example, does not roll back a changeset already stored by the Adapter.

Events are therefore appropriate for in-process, recoverable, or non-critical follow-up work. They are not a transaction boundary shared with the database commit and do not guarantee delivery to an external system.

Core Service Events

EventConfirmed factTypical use
unitCreatedThe Unit and initial data were createdLocal catalog cache or derived index
changesetCommittedA changeset was confirmed and advanced the revisionBroadcasts, metrics, derived indexes
unitsDeletedThe Adapter confirmed the deletion batchClear process-local related state
unitsRecoveredThe Adapter confirmed the recovery batchRestore process-local derived state

The Endpoint listens to changesetCommitted on the same Service and broadcasts the confirmed changeset to clients that joined the Unit in the current process. The Endpoint also exposes memberLeftUnit for explicit leave, disconnect, and Endpoint disposal.

Extension modules have independent extension points

History, Thread Comment, and Worktree do not inherit Middleware or Events from the core Collaboration Service:

  • History provides read and indexing Middleware;
  • Comment provides add, reply, edit, delete, solve, and list Middleware plus commentCommitted;
  • Worktree provides lifecycle and draft read/submit/commit Middleware plus creation, status, merge, and draft commit Events.

Install rules separately for every enabled module. Logging, tenant, or authorization Middleware on the core Service does not automatically cover them.

Continue with Identity and authorization for a concrete mapping from existing users and ACLs to these general Middleware boundaries.