WP Manifestindependent plugin directory
manifest / editor / gutenberg-sync-engines

Gutenberg Sync Engines

In active development: A comparison of possible sync engines and transports for Gutenberg RTC

by WordPress Contributors · github.com/automattic/gutenberg-sync-engines · website

3stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/automattic/gutenberg-sync-engines/archive/refs/heads/trunk.zip

Pluggable real-time collaboration engines and transports for Gutenberg.

What it provides

Engines (how concurrent edits merge):

  • intent-log: a server-authoritative log of typed intents; concurrent edits merge by transform, genuine conflicts are set aside for review, and no work is silently lost.
  • yjs-server: a server-authoritative CRDT: the vendored y-php library merges every update into a canonical room document server-side, compacts by itself, and materializes post content.
  • de-rtc: Distributed Editing's save-centric model: clients propose whole content against a named base version, the server three-way-merges every proposal. Genuine conflicts escalate instead of silently merging.

Transports (how updates move):

  • http-polling: short-poll POST /wp-sync/v1/updates (default).
  • http-long-polling: the same, held open until data is ready.
  • websocket: push over a persistent socket served by a bundled PHP daemon (wp collaboration sync-server). For local dev, npm run rtc:ws starts everything in one command (and npm run rtc:http switches back).

Storage (where a room's updates live):

  • Two plugin-owned tables, {prefix}sync_updates (the update log) and {prefix}sync_room_meta (lineage, awareness, engine bookkeeping), substituted for Gutenberg's default post-meta storage. No collaboration write touches post caches. On a site with a persistent object cache (Redis, Memcached), who is present in a room is kept in the cache rather than the database, the storage strategy the WordPress hosting performance tests recommended; a poll that changes nothing writes nothing. Activating the plugin creates the tables; deactivating it leaves them and every room in place; deleting the plugin (uninstall.php) or running wp collaboration storage drop removes them. wp collaboration storage status shows what a site has.

The active engine, and how editors get each other's changes (polling, polling with an advisory channel over WebRTC or a WebSocket, long polling, or WebSocket), are chosen on the plugin's Settings → Collaboration screen (or via wp_sync_engine / the WP_COLLABORATION_TRANSPORT config value).

Comparing the engines? Start with docs/. The short answer and the full trade-off — scorecard, feature parity, resource shapes, and each engine's known gaps — live in docs/engine-comparison.md; the transports are compared separately in docs/transports.md. Both are deliberately number-free. Run npm run bench for a report of what the plugin adds to a server on your own hardware, and npm run bench -- --suite=engines for the full engine-decision numbers.

Architecture

Both axes are independent registries with a client/server handshake: the server announces the active engine + transport, the client negotiates against what it has registered, and any mismatch degrades to a post lock rather than corruption. See Gutenberg's prototypes/sync/ARCHITECTURE.md for the full picture.

The plugin registers via:

  • PHP: the wp_sync_engines and wp_sync_transports filters.
  • JS: registerSyncEngine / registerSyncTransport, unlocked from @wordpress/sync's private APIs.

Development

A modified copy of Gutenberg at runtime is vendored as a git subtree in gutenberg/ and mounted by .wp-env.json so the local WordPress environment runs the exact Gutenberg the engines were built against. No separate checkout needed.

Setup

composer install          # PHP tooling (PHPCS/WPCS, PHPUnit + polyfills)
npm install               # JS tooling (@wordpress/scripts, wp-env, Playwright)
npm run build             # Build this plugin's client bundle

# Build the vendored Gutenberg.
cd gutenberg && npm install --ignore-scripts && npm run build && cd ..

Environment

npm run env start         # Start WordPress (Gutenberg subtree + this plugin)
npm run env stop          # Stop it

Alternatively, try it using WordPress Playground. Note: On the official WordPress playground, every browser tab is its own WordPress site, so a second tab cannot join the first tab's editing session. Instead, use a local Playground instance:

npm run playground

Tests

npm run test:js           # Jest — engines/providers + frozen-core vectors
npm run test:php          # PHPUnit in the wp-env tests container (loads the
                          # Gutenberg subtree as the framework, then the plugin)
npm run test:e2e          # Playwright — two-browser collaboration against the
                          # running env (needs `npx playwright install chromium`)

Benchmarks and tools

  • tests/benchmarks/ — a server-side engine benchmark harness: it drives any registered engine through the production ingest/read seam and reports service-time percentiles, payload and storage growth, and (for intent-log) merge-quality metrics; compare.js renders multiple runs side by side. See tests/benchmarks/README.md for how to run it and how to read the numbers.
  • tests/benchmarks/transport/ — a transport experience benchmark: two real browser clients measure edit-to-visible propagation latency and wire traffic (editing + idle) per transport. See its README.
  • tests/tools/ — Node CLI utilities: a long-running intent-log simulator sweep (node tests/tools/sweep.js), a manual two-tab sync observer against a live environment (node tests/tools/observe-two-tab-sync.mjs), and the frozen-core test-vector generators.

Testing by yourself

If you need to test behavior by yourself, you can open a separate browser and use this script in the console.

(async () => { const { subscribe, select } = wp.data; const clientId = await new Promise((resolve) => { const initial = select('core/block-editor').getSelectedBlockClientId(); if (initial) { resolve(initial); return; } const unsubscribe = subscribe(() => { const id = select('core/block-editor').getSelectedBlockClientId(); if (id) { unsubscribe(); resolve(id); } }); }); const doc = document.querySelector('iframe[name="editor-canvas"]')?.contentDocument ?? document; const blockEl = doc.querySelector(`[data-block="${clientId}"]`); const editable = blockEl?.querySelector('[contenteditable="true"]') ?? blockEl; if (!editable) { console.warn('No editable element found for block', clientId); return; } editable.focus(); const sel = doc.defaultView.getSelection(); if (!sel.rangeCount || !editable.contains(sel.anchorNode)) { const r = doc.createRange(); r.selectNodeContents(editable); r.collapse(false); sel.removeAllRanges(); sel.addRange(r); } let i = 0; const intervalId = setInterval(() => { const char = String(i % 10); const keyInit = { key: char, code: `Digit${char}`, keyCode: 48 + Number(char), which: 48 + Number(char), bubbles: true, cancelable: true }; editable.dispatchEvent(new KeyboardEvent('keydown', keyInit)); const notCancelled = editable.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true, cancelable: true })); if (notCancelled) { const s = doc.defaultView.getSelection(); if (s.rangeCount) { const r = s.getRangeAt(0); r.deleteContents(); const t = doc.createTextNode(char); r.insertNode(t); r.setStartAfter(t); r.setEndAfter(t); s.removeAllRanges(); s.addRange(r); } editable.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true })); } editable.dispatchEvent(new KeyboardEvent('keyup', keyInit)); i++; }, 60); window.__stopTyping = () => { clearInterval(intervalId); console.log('Stopped.'); }; console.log('Typing started on block', clientId, '— run window.__stopTyping() to stop.'); })();

It will keep typing and let you test different scenarios. You can stop it by entering window.__stopTyping() in the same console you ran the original command.