Collaboration Architecture Layers
This document is the source of truth for how Softmaple’s real-time collaboration code is layered. It defines what each layer owns, what it must not depend on, and how model bindings and awareness compose insideapps/*. Durable write invariants and EventConflictError semantics are
documented in
collaboration-consistency.md. The
runtime-independent room/session contract is specified in
collaboration-runtime.md.
The split is enforced by shared ESLint no-restricted-imports patterns and
awareness’s equivalent Biome rule (see Enforcement below).
Layers at a glance
@softmaple/binding-<surface> packages and in app-local binding
staging code, but not in model or awareness packages.
Layer 1: @softmaple/eg-walker
The CRDT runtime. Implements the Eg-walker paper directly.
Responsibilities
- Event graph — persistent DAG of inserts and deletes, columnar
on-disk format (
graph/). - Replay engine — prepare/effect pipeline that walks the graph and
produces the linear document state (
engine/). - Convergence — guaranteed identical state on all replicas after exchanging the same events.
- Persistence — columnar codec, critical-version checkpoints, topological ordering of events.
- Index-based base API — operations are
insert(index, text)/delete(index, length)against the current linear document. - Stable sequence anchors — the advanced
./anchorsentry identifies UTF-16 atoms by insert-event ID, event offset, and affinity. It remains sequence-shaped and knows nothing about blocks or editor selections.
Forbidden
@softmaple/eg-walker MUST NOT:
- Depend on
@softmaple/awareness(no presence, no cursors, no transport adapters). - Depend on
@softmaple/block-modelor any@softmaple/binding-*package; those are higher layers. - Depend on
@softmaple/collab-protocol; wire contracts are host-facing. - Depend on
@softmaple/collab-runtime; room semantics are a still higher host-facing layer. - Depend on any editor framework —
lexical,prosemirror-*,slate/slate-*, or equivalent. - Expose block IDs, DOM types, or editor selections. Stable sequence anchors are the deliberate exception to the base index-only API.
Rationale
eg-walker is the convergence guarantee for the whole product. Keeping it free of editor and presence concerns lets us reuse it under any editor we choose, run it in a worker or on the server, and reason about it in isolation when debugging divergence.Layer 2: @softmaple/block-model
The editor-agnostic rich-text document model. It is built on the stable
sequence and causal DAG supplied by EG-walker.
Responsibilities
- Block convergence — stable block markers, split/join/delete semantics, field-level causal LWW attributes, nesting, and remove-wins deletion.
- Inline convergence — text, line breaks, tabs, independent inline marks, and structured link ranges backed by stable sequence anchors.
- Batching and persistence boundary — JSON-safe
RichTextEventBatchvalues, deterministic bootstrap, remote buffering, deduplication, serialization, and one materialization per integrated batch. - Editor-independent selection positions — stable
{ blockId, anchor }endpoints and resolution back to{ blockId, offset }.
Forbidden
@softmaple/block-model MUST NOT:
- Depend on
@softmaple/awarenessor any transport/presence state. - Depend on a surface binding or editor framework.
- Depend on
@softmaple/collab-protocol; the protocol depends on model batches, never the reverse. - Depend on
@softmaple/collab-runtime; host room semantics depend on model values, never the reverse. - Expose Lexical node keys, DOM types, React components, or awareness users.
@softmaple/eg-walker and its ./anchors entry; that is
the intended direction of the model stack.
Layer 3: @softmaple/awareness
The presence and cursor layer. Editor-class-agnostic.
Responsibilities
- Presence state — who is online, who is in this document, status
(
active/idle/offline), last-seen timestamps. - Cursor and selection state — keeps the legacy single-block range and
the direction-preserving, cross-block
{ anchor, focus }stable range as dependency-free JSON shapes. - Transport adapters — pluggable backends (broadcast channel,
WebSocket, no-op) under
adapters/. - Rendering helpers — primitives (
PresenceBar,LiveCursor,SelectionHighlight,ActivityIndicator) and React hooks for the app shell to compose presence UI.
Forbidden
@softmaple/awareness MUST NOT:
- Depend on
@softmaple/eg-walker,@softmaple/block-model, or a@softmaple/binding-*package. Presence and convergence are independent; structurally mirrored JSON anchor types do not require a runtime import. - Depend on
@softmaple/collab-protocol; presence and durable document sync remain independent channels. - Depend on
@softmaple/collab-runtime; presence remains independent from durable room/session lifecycle. - Depend on any editor framework —
lexical,prosemirror-*, orslate/slate-*.
Rationale
Awareness is approximate by design (seeawareness-and-presence). It must never
gate document convergence and must never assume a particular editor.
This keeps the package safe to load in a worker, on the server (for
SSR-friendly presence snapshots), or alongside a non-Lexical editor.
Layer 4: @softmaple/binding-lexical
The thin concrete surface binding. Its framework-neutral core projects
between Lexical editor state and BlockDocument; its separate ./react
entry supplies lifecycle wiring with peer dependencies.
Responsibilities
- Observe supported Lexical nodes and emit one block-model transaction per committed Lexical update.
- Materialize a remote
BlockDocumentin one collaboration-tagged update, suppress feedback, coordinate IME, and preserve stable directional selections. - Fail fast on unsupported nodes or attributes.
Forbidden
@softmaple/binding-lexical MUST NOT:
- Import
@softmaple/eg-walkerdirectly; the block-model API is its model boundary. - Import
@softmaple/collab-protocol; the binding owns projection, not wire transport. - Import
@softmaple/collab-runtime; bindings do not own hosted room lifecycle. - Own network transport, durable persistence, user identity, awareness state, or remote-cursor rendering.
Layer 5: @softmaple/collab-protocol
The transport-independent wire contract shared by browser and collaboration
server hosts.
Responsibilities
- Define versioned auth, event, repair, durable-ack, ready, and error messages.
- Parse untrusted messages into validated
RichTextEventBatchvalues. - Cap per-message batch counts and reject unsupported protocol versions.
Forbidden
@softmaple/collab-protocol MUST NOT:
- Import EG-walker directly, a surface binding, awareness, or an editor
framework. Rich-text payloads enter through
@softmaple/block-model. - Own WebSocket connections, Supabase clients, database access, JWT checks, React components, or any other host runtime.
- Import
@softmaple/collab-runtime; wire contracts remain below room semantics.
Layer 6: @softmaple/collab-runtime
The runtime-independent server-side collaboration boundary. It defines how a
document room talks to peers and to host-provided capabilities without
choosing a deployment runtime or persistence implementation.
Responsibilities
- Define
DocumentRoom,RoomPeer, normalized document sessions, and room lifecycle contracts. - Define capabilities for durable event append/history reads, committed-event fan-out, connection admission/leases, and authorization refresh.
- Define ephemeral presence-room capabilities (
PresenceStore,PresenceFanout,PresenceSessionHooks) and a payload-opaquePresenceCodecseam, plus thePresenceRoomstate machine built on them. Presence shares no capability instance withDocumentRoom— a presence failure cannot block durable document convergence. - Reuse
@softmaple/collab-protocolmessages and validated batch shapes rather than creating a second browser protocol. - Specify durable append,
DurableAck, fan-out, and repair/resync ordering.
collaboration-runtime.md.
Forbidden
@softmaple/collab-runtime MUST NOT:
- Import Nitro, H3, Next.js, Node built-ins,
ioredis, Prisma,@softmaple/db, Supabase SDKs,cloudflare:workers, or other concrete host infrastructure. - Import EG-walker directly, awareness, a surface binding, an editor framework, React, UI packages, or app routing.
- Import
@softmaple/awarenessor any subpath (including@softmaple/awareness/protocoland@softmaple/awareness/types/presence) — presence payload shapes are opaque here; the host injects aPresenceCodec. - Implement convergence, block integration, or another CRDT. EG-walker and block-model retain those responsibilities.
- Own a browser transport or alter the wire protocol.
Host realtime coordination: apps/collab-nitro Redis adapters
Cross-instance fan-out, presence TTLs, and connection leases live in
apps/collab-nitro/server/utils/realtime. They are host-only concerns: model
packages, awareness, @softmaple/collab-protocol, and
@softmaple/collab-runtime must not import Redis clients or deployment
topology helpers. Browser Origin checks replace the former HMAC web-gateway
boundary; Supabase JWT and workspace authorization remain the user auth
boundary.
Layer 7: apps/*
The integration layer. Today that is apps/web (Next.js + Lexical)
and apps/playground (CRDT experiments).
Responsibilities
- Binding composition — instantiate
@softmaple/binding-lexicalwith aBlockReplica, editor, room lifecycle, and error handling. App-local bindings for surfaces not yet promoted to packages may also live here. - UI composition — wiring
@softmaple/awarenesscomponents into the app shell, choosing transport adapters, theming. - Identity and auth — mapping the app’s user model onto
PresenceUser. - Routing and persistence — document IDs, room IDs, hydration from the database.
- Runtime adapters — connect Nitro peers, Prisma/Postgres history, Redis
fan-out/leases, and Supabase authorization to
@softmaple/collab-runtimecontracts.
Forbidden
Apps are where durable document events, ephemeral awareness, editor bindings, identity, and UI meet. They may import the concrete editor framework for host composition, but model and awareness logic must not be reimplemented here.Enforcement
The rules above are enforced mechanically by each package’s existing linter. Model and binding packages use shared ESLint pattern sets; awareness expresses the same independence boundary in Biome:@softmaple/eg-walker(ESLint) — wired in viapackages/eg-walker/eslint.config.js, drawing patterns fromegWalkerCollaborationPatternsin@softmaple/eslint-config/collaboration-layers. Forbids awareness, reverse imports from block-model/binding packages, and editor frameworks.@softmaple/block-model(ESLint) — usesblockModelCollaborationPatterns. It allows EG-walker and its anchor entry, while forbidding awareness, binding packages, and editor frameworks.@softmaple/binding-lexical(ESLint) — usesblockModelBindingCollaborationPatterns. It allows block-model, Lexical, and React, while forbidding a direct EG-walker import.@softmaple/collab-protocol(ESLint) — usescollabProtocolCollaborationPatterns. It allows block-model wire values, while forbidding lower-layer bypasses, bindings, awareness, editors, and app-owned database/auth/server/UI runtimes.@softmaple/collab-runtime(ESLint) — usescollabRuntimeCollaborationPatternsas an import allowlist. Production sources may import local modules,@softmaple/collab-protocol, and@softmaple/block-model; bare imports of deployment, persistence, auth, server, editor, and UI runtimes fail lint. CI also runsturbo boundariesfor the package so a relative import cannot escape its workspace boundary.@softmaple/awareness(Biome) — wired in via thestyle/noRestrictedImportsrule inpackages/awareness/biome.jsonc. Forbids EG-walker, block-model, binding-lexical, collab-protocol, collab-runtime, and editor frameworks (including subpath imports).
@lexical/*/**, prosemirror-*/**, slate-*/**)
are spelled out explicitly in both configs because the glob * does
not cross / in either matcher; without them an import like
@lexical/react/LexicalComposer would slip past the rule. Biome’s
noRestrictedImports additionally requires bare specifiers
(@softmaple/eg-walker, @softmaple/block-model,
@softmaple/binding-lexical, lexical, slate) to live in paths
rather than patterns, so those are listed separately in
biome.jsonc — the JSONC config also carries an inline comment
right above the rule restating this gotcha for the next editor.
A unit test in packages/eslint-config lints deliberately-bad imports
against every ESLint pattern set and also asserts each intended
downward import remains legal. The same test applies each pattern set to its
real package source tree. The awareness Biome boundary test runs the real
config against generated import fixtures, including bare and subpath forms,
and CI lints the awareness source tree with that config.
If you need to add a new editor framework, extend the
module-internal EDITOR_FRAMEWORK_PATTERNS constant inside
packages/eslint-config/collaboration-layers.js (it is intentionally
not exported — there is no out-of-module consumer) and the matching
style/noRestrictedImports block in packages/awareness/biome.jsonc
in the same change. If a new collaboration package is introduced, add
its dependency direction to the relevant pattern set and package lint
config rather than applying one universal deny list.
Generic position contract
The collaboration foundation must work for plain text, rich text, block, canvas/whiteboard, node-based, and IDE-like editors. To keep model and awareness packages surface-agnostic, positions and ranges flow through the following shapes:- 1D index — for sequence editors (plain text, rich text linearised).
Used by
@softmaple/eg-walker’sExternalOperation({ type: "insert", index, text }/{ type: "delete", index, length }) and by@softmaple/awareness’smapping/subpath (PositionOperation,PositionRange). EG-walker string indices and anchor offsets are UTF-16 code-unit boundaries and reject positions inside a surrogate pair. - Block-local offsets and stable endpoints — legacy textarea presence
keeps
{ blockId, from, to }. Rich-text presence uses directional{ anchor, focus }endpoints, each shaped as{ blockId, anchor: SequenceAnchor }.@softmaple/block-modelcaptures and resolves those stable endpoints without awareness or Lexical imports, preserving backwards and cross-block selections. { x, y }(or an arbitrary opaque blob) — for canvas / whiteboard editors. Canvas-style positions are not baked into awareness’s core types. Adapters carry them throughPresenceMeta’s open-ended[key: string]: unknownfield, and renderer components (LiveCursor,SelectionHighlight) already accept post-resolved screen coordinates (LiveCursorPoint { x, y },HighlightRect { x, y, width, height }) so a canvas integration never has to round-trip throughCursorPosition.
Structural remote-event result (issue #747)
EgWalkerReplica.applyRemoteEvent returns an
ApplyRemoteEventResult describing the integration outcome
structurally, so consumers do not have to infer it from a getText()
pre/post comparison:
"integrated"— the event landed in the graph and advanced the document.operationcarries the engine-attributedPositionOperationwhen the engine took the incremental advance path and produced exactly one transformed op; otherwisenull(visible no-op, multi-op coalesced delete, or partial/full replay). Consumers driving selection mapping should treatnullas “remap from text diff”, not “skip the remap”."buffered"— at least one parent is missing, the event is queued, and the document is unchanged. The buffered event flushes automatically when its last parent arrives, as a side effect of the parent’sapplyRemoteEventcall. That flush is not reported through a separate result — a consumer that needs per-flush notifications must currently re-derive them by walking the post-call text."duplicate"— the event id is already in the graph or already buffered; the call is a no-op.
PositionOperation shape returned from eg-walker mirrors the type
defined by @softmaple/awareness/mapping. Each package owns its own
copy so the layer boundary holds (eg-walker still does not depend on
awareness), and structural typing lets consumers pass either through
mapTextareaSelectionThroughOperation interchangeably.
Why this is safer than a getText() comparison
The pre-#747 consumer code in
apps/playground/src/modules/collaborative-editor/use-collaborative-editor.ts
inferred integration from a text side effect:
- It is behavioral, not structural. Any future change that lets an integrated event produce a zero-width visible change (a delete that fully overlaps already-deleted characters, an empty insert sliding through a coalescing path, IME compositions in #704) would silently flip the inferred outcome.
- It materialises the full text twice per event. The structural API
is free on the common (incremental-advance) path; consumers that
need a mapping op only fall back to a text diff when the engine
returns
operation: null(partial/full replay, multi-op coalesced delete, visible no-op), which is the minority case. - It conflates “integrated” with “buffered” with “duplicate” into a single boolean. The new API distinguishes them so a buffered event cannot be mistaken for an integrated one.
Buffering semantics
RemoteEventBuffer (in core/internals/) still owns the same
state machine: an event with a missing parent is keyed on the
missing parent id; when that parent later arrives, every queued child
is re-tried in causal order via recursive tryAccept calls. The only
behavioral change is the return shape — pending/applied/duplicate
ordering, idempotence, and causal-flush semantics are preserved.
Audit (issue #727)
The collaboration packages’ public types were audited against the contract above and found aligned with their assigned layer:@softmaple/eg-walkerpublic types (packages/eg-walker/src/types/) describe a 1D sequence plus JSON-safe sequence anchors and never reference blocks, DOM, or any editor framework.@softmaple/block-modelowns block IDs, stable block endpoints,BlockDocument, and rich-text batches, but has no Lexical, React, awareness, DOM, or transport types.@softmaple/binding-lexicaldeliberately owns Lexical projection and a React lifecycle entry, while its model boundary isBlockReplicarather than EG-walker internals.@softmaple/awarenesspublic types (packages/awareness/src/types/) retain the legacy single-block range and add direction-preserving stable{ anchor, focus }endpoints by structurally mirroring the JSON anchor shape. Renderer components accept resolved screen coordinates rather than baking editor geometry into the presence model.
When to update this doc
Update this page whenever any of the following change:- A layer gains or loses a responsibility.
- The set of forbidden dependencies changes (e.g. adding a new editor framework or model package to the relevant deny list).
- A new top-level package joins the collaboration stack.