Real-time collaboration: add new REST endpoints, setting, and registered post meta.

Syncs/merges the PHP changes from the Gutenberg PR https://github.com/WordPress/gutenberg/pull/75366.

In Gutenberg, we have added support for real-time collaboration using CRDT documents (via the [Yjs library](https://yjs.dev/)). This work has suggested the following additions to WordPress:

1. A default "sync provider" based on HTTP polling that allows collaborators to share updates with each other. Previously, we relied on WebRTC connections between collaborators for this purpose, but it proved unreliable under many network conditions.
   - Our solution is designed to work on any WordPress installation. 
   - HTTP polling is the transport we identified as most likely to work universally.
   - Given the isolation and lifecycle of PHP processes, updates must be stored centrally in order to be shared among peers. We have chosen to store updates in post meta against a special post type, but alternate storage mechanisms are possible.
   - Collaborative editing can involve syncing multiple CRDT documents. To limit the number of connections consumed by this provider, requests are batched.
   - To prevent unbounded linear growth, updates are periodically compacted.
   - To avoid excessive load on lower-resourced hosts, this provider will benefit from usage limits (e.g., a maximum of three connected collaborators) enforced by the client (Gutenberg).

2. A new registered post meta that allows Gutenberg to persist CRDT documents alongside posts.
   - This provides all collaborators with a "shared starting point" for the collaborative session, which avoids duplicate updates.
   - Content stored in the WordPress database always remains the source of truth. If the content differs from the persisted CRDT document, the CRDT document is updated to match the database.

3. A new Writing setting that allows users to opt-in to real-time collaboration.
   - Enabling real-time collaboration disables post lock functionality and connects users to the sync provider.

4. A behavior change to autosaves is needed. When the the original author is editing a draft post (post_status == 'draft' OR 'auto-draft') and they hold the post lock, the autosave targets the actual post instead of an autosave revision. This puts the post data and the persisted CRDT document out of sync and leads to duplicate updates. When real-time collaboration is enabled, all collaborators must autosave in the same way.

This PR provides a proposed implementation of the changes above. This corresponding Gutenberg PR moves the work from the `experimental` directory to `lib/compat`:

https://github.com/WordPress/gutenberg/pull/75366

Cumulative work to add this functionality can be found using this label:

https://github.com/WordPress/gutenberg/issues?q=label%3A%22%5BFeature%5D%20Real-time%20Collaboration%22%20is%3Apr

Developed https://github.com/WordPress/wordpress-develop/pull/10894.

Props czarate, paulkevan, ellatrix, timothyblynjacobs, westonruter, jorgefilipecosta, mindctrl.
Fixes #64622.

Built from https://develop.svn.wordpress.org/trunk@61689


git-svn-id: http://core.svn.wordpress.org/trunk@60997 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
ellatrix
2026-02-19 10:26:41 +00:00
parent b3ac058a85
commit 997cc3dd3d
15 changed files with 1155 additions and 5 deletions

View File

@@ -563,6 +563,9 @@ function populate_options( array $options = array() ) {
// 6.9.0
'wp_notes_notify' => 1,
// 7.0.0
'enable_real_time_collaboration' => 1,
);
// 3.3.0

View File

@@ -109,6 +109,13 @@ unset( $post_formats['standard'] );
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="enable_real_time_collaboration"><?php _e( 'Collaboration' ); ?></label></th>
<td>
<input name="enable_real_time_collaboration" type="checkbox" id="enable_real_time_collaboration" value="1" <?php checked( '1', get_option( 'enable_real_time_collaboration' ) ); ?> />
<label for="enable_real_time_collaboration"><?php _e( 'Enable real-time collaboration' ); ?></label>
</td>
</tr>
<?php
if ( get_option( 'link_manager_enabled' ) ) :
?>

View File

@@ -153,6 +153,7 @@ $allowed_options = array(
'default_email_category',
'default_link_category',
'default_post_format',
'enable_real_time_collaboration',
),
);
$allowed_options['misc'] = array();

View File

@@ -1,7 +1,7 @@
##
## Bundle of CA Root Certificates
##
## Certificate data from Mozilla as of: Tue Dec 2 04:12:02 2025 GMT
## Certificate data from Mozilla as of: Tue Nov 4 04:12:02 2025 GMT
##
## Find updated versions here: https://curl.se/docs/caextract.html
##
@@ -15,8 +15,8 @@
## an Apache+mod_ssl webserver for SSL client authentication.
## Just configure this file as the SSLCACertificateFile.
##
## Conversion done with mk-ca-bundle.pl version 1.30.
## SHA256: a903b3cd05231e39332515ef7ebe37e697262f39515a52015c23c62805b73cd0
## Conversion done with mk-ca-bundle.pl version 1.29.
## SHA256: 039132bff5179ce57cec5803ba59fe37abe6d0297aeb538c5af27847f0702517
##
@@ -3167,6 +3167,96 @@ bbd+NvBNEU/zy4k6LHiRUKNbwMp1JvK/kF0LgoxgKJ/GcJpo5PECMFxYDlZ2z1jD1xCMuo6u47xk
dUfFVZDj/bpV6wfEU6s3qe4hsiFbYI89MvHVI5TWWA==
-----END CERTIFICATE-----
CommScope Public Trust ECC Root-01
==================================
-----BEGIN CERTIFICATE-----
MIICHTCCAaOgAwIBAgIUQ3CCd89NXTTxyq4yLzf39H91oJ4wCgYIKoZIzj0EAwMwTjELMAkGA1UE
BhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBUcnVz
dCBFQ0MgUm9vdC0wMTAeFw0yMTA0MjgxNzM1NDNaFw00NjA0MjgxNzM1NDJaME4xCzAJBgNVBAYT
AlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3Qg
RUNDIFJvb3QtMDEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARLNumuV16ocNfQj3Rid8NeeqrltqLx
eP0CflfdkXmcbLlSiFS8LwS+uM32ENEp7LXQoMPwiXAZu1FlxUOcw5tjnSCDPgYLpkJEhRGnSjot
6dZoL0hOUysHP029uax3OVejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G
A1UdDgQWBBSOB2LAUN3GGQYARnQE9/OufXVNMDAKBggqhkjOPQQDAwNoADBlAjEAnDPfQeMjqEI2
Jpc1XHvr20v4qotzVRVcrHgpD7oh2MSg2NED3W3ROT3Ek2DS43KyAjB8xX6I01D1HiXo+k515liW
pDVfG2XqYZpwI7UNo5uSUm9poIyNStDuiw7LR47QjRE=
-----END CERTIFICATE-----
CommScope Public Trust ECC Root-02
==================================
-----BEGIN CERTIFICATE-----
MIICHDCCAaOgAwIBAgIUKP2ZYEFHpgE6yhR7H+/5aAiDXX0wCgYIKoZIzj0EAwMwTjELMAkGA1UE
BhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBUcnVz
dCBFQ0MgUm9vdC0wMjAeFw0yMTA0MjgxNzQ0NTRaFw00NjA0MjgxNzQ0NTNaME4xCzAJBgNVBAYT
AlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3Qg
RUNDIFJvb3QtMDIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR4MIHoYx7l63FRD/cHB8o5mXxO1Q/M
MDALj2aTPs+9xYa9+bG3tD60B8jzljHz7aRP+KNOjSkVWLjVb3/ubCK1sK9IRQq9qEmUv4RDsNuE
SgMjGWdqb8FuvAY5N9GIIvejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G
A1UdDgQWBBTmGHX/72DehKT1RsfeSlXjMjZ59TAKBggqhkjOPQQDAwNnADBkAjAmc0l6tqvmSfR9
Uj/UQQSugEODZXW5hYA4O9Zv5JOGq4/nich/m35rChJVYaoR4HkCMHfoMXGsPHED1oQmHhS48zs7
3u1Z/GtMMH9ZzkXpc2AVmkzw5l4lIhVtwodZ0LKOag==
-----END CERTIFICATE-----
CommScope Public Trust RSA Root-01
==================================
-----BEGIN CERTIFICATE-----
MIIFbDCCA1SgAwIBAgIUPgNJgXUWdDGOTKvVxZAplsU5EN0wDQYJKoZIhvcNAQELBQAwTjELMAkG
A1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBU
cnVzdCBSU0EgUm9vdC0wMTAeFw0yMTA0MjgxNjQ1NTRaFw00NjA0MjgxNjQ1NTNaME4xCzAJBgNV
BAYTAlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1
c3QgUlNBIFJvb3QtMDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwSGWjDR1C45Ft
nYSkYZYSwu3D2iM0GXb26v1VWvZVAVMP8syMl0+5UMuzAURWlv2bKOx7dAvnQmtVzslhsuitQDy6
uUEKBU8bJoWPQ7VAtYXR1HHcg0Hz9kXHgKKEUJdGzqAMxGBWBB0HW0alDrJLpA6lfO741GIDuZNq
ihS4cPgugkY4Iw50x2tBt9Apo52AsH53k2NC+zSDO3OjWiE260f6GBfZumbCk6SP/F2krfxQapWs
vCQz0b2If4b19bJzKo98rwjyGpg/qYFlP8GMicWWMJoKz/TUyDTtnS+8jTiGU+6Xn6myY5QXjQ/c
Zip8UlF1y5mO6D1cv547KI2DAg+pn3LiLCuz3GaXAEDQpFSOm117RTYm1nJD68/A6g3czhLmfTif
BSeolz7pUcZsBSjBAg/pGG3svZwG1KdJ9FQFa2ww8esD1eo9anbCyxooSU1/ZOD6K9pzg4H/kQO9
lLvkuI6cMmPNn7togbGEW682v3fuHX/3SZtS7NJ3Wn2RnU3COS3kuoL4b/JOHg9O5j9ZpSPcPYeo
KFgo0fEbNttPxP/hjFtyjMcmAyejOQoBqsCyMWCDIqFPEgkBEa801M/XrmLTBQe0MXXgDW1XT2mH
+VepuhX2yFJtocucH+X8eKg1mp9BFM6ltM6UCBwJrVbl2rZJmkrqYxhTnCwuwwIDAQABo0IwQDAP
BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUN12mmnQywsL5x6YVEFm4
5P3luG0wDQYJKoZIhvcNAQELBQADggIBAK+nz97/4L1CjU3lIpbfaOp9TSp90K09FlxD533Ahuh6
NWPxzIHIxgvoLlI1pKZJkGNRrDSsBTtXAOnTYtPZKdVUvhwQkZyybf5Z/Xn36lbQnmhUQo8mUuJM
3y+Xpi/SB5io82BdS5pYV4jvguX6r2yBS5KPQJqTRlnLX3gWsWc+QgvfKNmwrZggvkN80V4aCRck
jXtdlemrwWCrWxhkgPut4AZ9HcpZuPN4KWfGVh2vtrV0KnahP/t1MJ+UXjulYPPLXAziDslg+Mkf
Foom3ecnf+slpoq9uC02EJqxWE2aaE9gVOX2RhOOiKy8IUISrcZKiX2bwdgt6ZYD9KJ0DLwAHb/W
NyVntHKLr4W96ioDj8z7PEQkguIBpQtZtjSNMgsSDesnwv1B10A8ckYpwIzqug/xBpMu95yo9GA+
o/E4Xo4TwbM6l4c/ksp4qRyv0LAbJh6+cOx69TOY6lz/KwsETkPdY34Op054A5U+1C0wlREQKC6/
oAI+/15Z0wUOlV9TRe9rh9VIzRamloPh37MG88EU26fsHItdkJANclHnYfkUyq+Dj7+vsQpZXdxc
1+SWrVtgHdqul7I52Qb1dgAT+GhMIbA1xNxVssnBQVocicCMb3SgazNNtQEo/a2tiRc7ppqEvOuM
6sRxJKi6KfkIsidWNTJf6jn7MZrVGczw
-----END CERTIFICATE-----
CommScope Public Trust RSA Root-02
==================================
-----BEGIN CERTIFICATE-----
MIIFbDCCA1SgAwIBAgIUVBa/O345lXGN0aoApYYNK496BU4wDQYJKoZIhvcNAQELBQAwTjELMAkG
A1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBU
cnVzdCBSU0EgUm9vdC0wMjAeFw0yMTA0MjgxNzE2NDNaFw00NjA0MjgxNzE2NDJaME4xCzAJBgNV
BAYTAlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1
c3QgUlNBIFJvb3QtMDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDh+g77aAASyE3V
rCLENQE7xVTlWXZjpX/rwcRqmL0yjReA61260WI9JSMZNRTpf4mnG2I81lDnNJUDMrG0kyI9p+Kx
7eZ7Ti6Hmw0zdQreqjXnfuU2mKKuJZ6VszKWpCtYHu8//mI0SFHRtI1CrWDaSWqVcN3SAOLMV2MC
e5bdSZdbkk6V0/nLKR8YSvgBKtJjCW4k6YnS5cciTNxzhkcAqg2Ijq6FfUrpuzNPDlJwnZXjfG2W
Wy09X6GDRl224yW4fKcZgBzqZUPckXk2LHR88mcGyYnJ27/aaL8j7dxrrSiDeS/sOKUNNwFnJ5rp
M9kzXzehxfCrPfp4sOcsn/Y+n2Dg70jpkEUeBVF4GiwSLFworA2iI540jwXmojPOEXcT1A6kHkIf
hs1w/tkuFT0du7jyU1fbzMZ0KZwYszZ1OC4PVKH4kh+Jlk+71O6d6Ts2QrUKOyrUZHk2EOH5kQMr
eyBUzQ0ZGshBMjTRsJnhkB4BQDa1t/qp5Xd1pCKBXbCL5CcSD1SIxtuFdOa3wNemKfrb3vOTlycE
VS8KbzfFPROvCgCpLIscgSjX74Yxqa7ybrjKaixUR9gqiC6vwQcQeKwRoi9C8DfF8rhW3Q5iLc4t
Vn5V8qdE9isy9COoR+jUKgF4z2rDN6ieZdIs5fq6M8EGRPbmz6UNp2YINIos8wIDAQABo0IwQDAP
BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUR9DnsSL/nSz12Vdgs7Gx
cJXvYXowDQYJKoZIhvcNAQELBQADggIBAIZpsU0v6Z9PIpNojuQhmaPORVMbc0RTAIFhzTHjCLqB
KCh6krm2qMhDnscTJk3C2OVVnJJdUNjCK9v+5qiXz1I6JMNlZFxHMaNlNRPDk7n3+VGXu6TwYofF
1gbTl4MgqX67tiHCpQ2EAOHyJxCDut0DgdXdaMNmEMjRdrSzbymeAPnCKfWxkxlSaRosTKCL4BWa
MS/TiJVZbuXEs1DIFAhKm4sTg7GkcrI7djNB3NyqpgdvHSQSn8h2vS/ZjvQs7rfSOBAkNlEv41xd
gSGn2rtO/+YHqP65DSdsu3BaVXoT6fEqSWnHX4dXTEN5bTpl6TBcQe7rd6VzEojov32u5cSoHw2O
HG1QAk8mGEPej1WFsQs3BWDJVTkSBKEqz3EWnzZRSb9wO55nnPt7eck5HHisd5FUmrh1CoFSl+Nm
YWvtPjgelmFV4ZFUjO2MJB+ByRCac5krFk5yAD9UG/iNuovnFNa2RU9g7Jauwy8CTl2dlklyALKr
dVwPaFsdZcJfMw8eD/A7hvWwTruc9+olBdytoptLFwG+Qt81IR2tq670v64fG9PiO/yzcnMcmyiQ
iRM9HcEARwmWmjgb3bHPDcK0RPOWlc4yOo80nOAXx17Org3bhzjlP1v9mxnhMUF6cKojawHhRUzN
lM47ni3niAIi9G7oyOzWPPO5std3eqx7
-----END CERTIFICATE-----
Telekom Security TLS ECC Root 2020
==================================
-----BEGIN CERTIFICATE-----

View File

@@ -0,0 +1,24 @@
<?php
/**
* Bootstraps collaborative editing.
*
* @package WordPress
* @since 7.0.0
*/
/**
* Injects the real-time collaboration setting into a global variable.
*
* @since 7.0.0
*
* @access private
*/
function wp_collaboration_inject_setting() {
if ( get_option( 'enable_real_time_collaboration' ) ) {
wp_add_inline_script(
'wp-core-data',
'window._wpCollaborationEnabled = true;',
'after'
);
}
}

View File

@@ -0,0 +1,505 @@
<?php
/**
* WP_HTTP_Polling_Sync_Server class
*
* @package WordPress
*/
/**
* Core class that contains an HTTP server used for collaborative editing.
*
* @since 7.0.0
* @access private
*/
class WP_HTTP_Polling_Sync_Server {
/**
* REST API namespace.
*
* @since 7.0.0
* @var string
*/
const REST_NAMESPACE = 'wp-sync/v1';
/**
* Awareness timeout in seconds. Clients that haven't updated
* their awareness state within this time are considered disconnected.
*
* @since 7.0.0
* @var int
*/
const AWARENESS_TIMEOUT = 30;
/**
* Threshold used to signal clients to send a compaction update.
*
* @since 7.0.0
* @var int
*/
const COMPACTION_THRESHOLD = 50;
/**
* Sync update type: compaction.
*
* @since 7.0.0
* @var string
*/
const UPDATE_TYPE_COMPACTION = 'compaction';
/**
* Sync update type: sync step 1.
*
* @since 7.0.0
* @var string
*/
const UPDATE_TYPE_SYNC_STEP1 = 'sync_step1';
/**
* Sync update type: sync step 2.
*
* @since 7.0.0
* @var string
*/
const UPDATE_TYPE_SYNC_STEP2 = 'sync_step2';
/**
* Sync update type: regular update.
*
* @since 7.0.0
* @var string
*/
const UPDATE_TYPE_UPDATE = 'update';
/**
* Storage backend for sync updates.
*
* @since 7.0.0
*/
private WP_Sync_Storage $storage;
/**
* Constructor.
*
* @since 7.0.0
*
* @param WP_Sync_Storage $storage Storage backend for sync updates.
*/
public function __construct( WP_Sync_Storage $storage ) {
$this->storage = $storage;
}
/**
* Registers REST API routes.
*
* @since 7.0.0
*/
public function register_routes(): void {
$typed_update_args = array(
'properties' => array(
'data' => array(
'type' => 'string',
'required' => true,
),
'type' => array(
'type' => 'string',
'required' => true,
'enum' => array(
self::UPDATE_TYPE_COMPACTION,
self::UPDATE_TYPE_SYNC_STEP1,
self::UPDATE_TYPE_SYNC_STEP2,
self::UPDATE_TYPE_UPDATE,
),
),
),
'required' => true,
'type' => 'object',
);
$room_args = array(
'after' => array(
'minimum' => 0,
'required' => true,
'type' => 'integer',
),
'awareness' => array(
'required' => true,
'type' => 'object',
),
'client_id' => array(
'minimum' => 1,
'required' => true,
'type' => 'integer',
),
'room' => array(
'required' => true,
'type' => 'string',
'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$',
),
'updates' => array(
'items' => $typed_update_args,
'minItems' => 0,
'required' => true,
'type' => 'array',
),
);
register_rest_route(
self::REST_NAMESPACE,
'/updates',
array(
'methods' => array( WP_REST_Server::CREATABLE ),
'callback' => array( $this, 'handle_request' ),
'permission_callback' => array( $this, 'check_permissions' ),
'args' => array(
'rooms' => array(
'items' => array(
'properties' => $room_args,
'type' => 'object',
),
'required' => true,
'type' => 'array',
),
),
)
);
}
/**
* Checks if the current user has permission to access a room.
*
* @since 7.0.0
*
* @param WP_REST_Request $request The REST request.
* @return bool|WP_Error True if user has permission, otherwise WP_Error with details.
*/
public function check_permissions( WP_REST_Request $request ) {
// Minimum cap check. Is user logged in with a contributor role or higher?
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'rest_cannot_edit',
__( 'You do not have permission to perform this action' ),
array( 'status' => rest_authorization_required_code() )
);
}
$rooms = $request['rooms'];
foreach ( $rooms as $room ) {
$room = $room['room'];
$type_parts = explode( '/', $room, 2 );
$object_parts = explode( ':', $type_parts[1] ?? '', 2 );
$entity_kind = $type_parts[0];
$entity_name = $object_parts[0];
$object_id = $object_parts[1] ?? null;
if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) {
return new WP_Error(
'rest_cannot_edit',
sprintf(
/* translators: %s: The room name encodes the current entity being synced. */
__( 'You do not have permission to sync this entity: %s.' ),
$room
),
array( 'status' => rest_authorization_required_code() )
);
}
}
return true;
}
/**
* Handles request: stores sync updates and awareness data, and returns
* updates the client is missing.
*
* @since 7.0.0
*
* @param WP_REST_Request $request The REST request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function handle_request( WP_REST_Request $request ) {
$rooms = $request['rooms'];
$response = array(
'rooms' => array(),
);
foreach ( $rooms as $room_request ) {
$awareness = $room_request['awareness'];
$client_id = $room_request['client_id'];
$cursor = $room_request['after'];
$room = $room_request['room'];
// Merge awareness state.
$merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness );
// The lowest client ID is nominated to perform compaction when needed.
$is_compactor = false;
if ( count( $merged_awareness ) > 0 ) {
$is_compactor = min( array_keys( $merged_awareness ) ) === $client_id;
}
// Process each update according to its type.
foreach ( $room_request['updates'] as $update ) {
$result = $this->process_sync_update( $room, $client_id, $cursor, $update );
if ( is_wp_error( $result ) ) {
return $result;
}
}
// Get updates for this client.
$room_response = $this->get_updates( $room, $client_id, $cursor, $is_compactor );
$room_response['awareness'] = $merged_awareness;
$response['rooms'][] = $room_response;
}
return new WP_REST_Response( $response, 200 );
}
/**
* Checks if the current user can sync a specific entity type.
*
* @since 7.0.0
*
* @param string $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'.
* @param string $entity_name The entity name, e.g. 'post', 'category', 'site'.
* @param string|null $object_id The object ID / entity key for single entities, null for collections.
* @return bool True if user has permission, otherwise false.
*/
private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool {
// Handle single post type entities with a defined object ID.
if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) {
return current_user_can( 'edit_post', (int) $object_id );
}
// Handle single taxonomy term entities with a defined object ID.
if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) {
$taxonomy = get_taxonomy( $entity_name );
return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms );
}
// All the remaining checks are for collections. If an object ID is provided,
// reject the request.
if ( null !== $object_id ) {
return false;
}
// For postType collections, check if the user can edit posts of this type.
if ( 'postType' === $entity_kind ) {
$post_type_object = get_post_type_object( $entity_name );
if ( ! isset( $post_type_object->cap->edit_posts ) ) {
return false;
}
return current_user_can( $post_type_object->cap->edit_posts );
}
// Collection syncing does not exchange entity data. It only signals if
// another user has updated an entity in the collection. Therefore, we only
// compare against an allow list of collection types.
$allowed_collection_entity_kinds = array(
'postType',
'root',
'taxonomy',
);
return in_array( $entity_kind, $allowed_collection_entity_kinds, true );
}
/**
* Processes and stores an awareness update from a client.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $client_id Client identifier.
* @param array<string, mixed>|null $awareness_update Awareness state sent by the client.
* @return array<int, array<string, mixed>> Map of client ID to awareness state.
*/
private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array {
$existing_awareness = $this->storage->get_awareness_state( $room );
$updated_awareness = array();
$current_time = time();
foreach ( $existing_awareness as $entry ) {
// Remove this client's entry (it will be updated below).
if ( $client_id === $entry['client_id'] ) {
continue;
}
// Remove entries that have expired.
if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) {
continue;
}
$updated_awareness[] = $entry;
}
// Add this client's awareness state.
if ( null !== $awareness_update ) {
$updated_awareness[] = array(
'client_id' => $client_id,
'state' => $awareness_update,
'updated_at' => $current_time,
);
}
// This action can fail, but it shouldn't fail the entire request.
$this->storage->set_awareness_state( $room, $updated_awareness );
// Convert to client_id => state map for response.
$response = array();
foreach ( $updated_awareness as $entry ) {
$response[ $entry['client_id'] ] = $entry['state'];
}
return $response;
}
/**
* Processes a sync update based on its type.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $client_id Client identifier.
* @param int $cursor Client cursor (marker of last seen update).
* @param array{data: string, type: string} $update Sync update.
* @return true|WP_Error True on success, WP_Error on storage failure.
*/
private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) {
$data = $update['data'];
$type = $update['type'];
switch ( $type ) {
case self::UPDATE_TYPE_COMPACTION:
/*
* Compaction replaces updates the client has already seen. Only remove
* updates with markers before the client's cursor to preserve updates
* that arrived since the client's last sync.
*
* Check for a newer compaction update first. If one exists, skip this
* compaction to avoid overwriting it.
*/
$updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
$has_newer_compaction = false;
foreach ( $updates_after_cursor as $existing ) {
if ( self::UPDATE_TYPE_COMPACTION === $existing['type'] ) {
$has_newer_compaction = true;
break;
}
}
if ( ! $has_newer_compaction ) {
if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) {
return new WP_Error(
'rest_sync_storage_error',
__( 'Failed to remove updates during compaction.' ),
array( 'status' => 500 )
);
}
return $this->add_update( $room, $client_id, $type, $data );
}
break;
case self::UPDATE_TYPE_SYNC_STEP1:
case self::UPDATE_TYPE_SYNC_STEP2:
case self::UPDATE_TYPE_UPDATE:
/*
* Sync step 1 announces a client's state vector. Other clients need
* to see it so they can respond with sync_step2 containing missing
* updates. The cursor-based filtering prevents re-delivery.
*
* Sync step 2 contains updates for a specific client.
*
* All updates are stored persistently.
*/
return $this->add_update( $room, $client_id, $type, $data );
}
return new WP_Error(
'rest_invalid_update_type',
__( 'Invalid sync update type.' ),
array( 'status' => 400 )
);
}
/**
* Adds an update to a room's update list via storage.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $client_id Client identifier.
* @param string $type Update type (sync_step1, sync_step2, update, compaction).
* @param string $data Base64-encoded update data.
* @return true|WP_Error True on success, WP_Error on storage failure.
*/
private function add_update( string $room, int $client_id, string $type, string $data ) {
$update = array(
'client_id' => $client_id,
'data' => $data,
'type' => $type,
);
if ( ! $this->storage->add_update( $room, $update ) ) {
return new WP_Error(
'rest_sync_storage_error',
__( 'Failed to store sync update.' ),
array( 'status' => 500 )
);
}
return true;
}
/**
* Gets sync updates for a specific client from a room after a given cursor.
*
* Delegates cursor-based retrieval to the storage layer, then applies
* client-specific filtering and compaction logic.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $client_id Client identifier.
* @param int $cursor Return updates after this cursor.
* @param bool $is_compactor True if this client is nominated to perform compaction.
* @return array{
* end_cursor: int,
* should_compact: bool,
* room: string,
* total_updates: int,
* updates: array<int, array{data: string, type: string}>,
* } Response data for this room.
*/
private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array {
$updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
$total_updates = $this->storage->get_update_count( $room );
// Filter out this client's updates, except compaction updates.
$typed_updates = array();
foreach ( $updates_after_cursor as $update ) {
if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) {
continue;
}
$typed_updates[] = array(
'data' => $update['data'],
'type' => $update['type'],
);
}
$should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD;
return array(
'end_cursor' => $this->storage->get_cursor( $room ),
'room' => $room,
'should_compact' => $should_compact,
'total_updates' => $total_updates,
'updates' => $typed_updates,
);
}
}

View File

@@ -0,0 +1,322 @@
<?php
/**
* WP_Sync_Post_Meta_Storage class
*
* @package WordPress
*/
/**
* Core class that provides an interface for storing and retrieving sync
* updates and awareness data during a collaborative session.
*
* Data is stored as post meta on a dedicated post per room of a custom post type.
*
* @since 7.0.0
*
* @access private
*/
class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage {
/**
* Post type for sync storage.
*
* @since 7.0.0
* @var string
*/
const POST_TYPE = 'wp_sync_storage';
/**
* Meta key for awareness state.
*
* @since 7.0.0
* @var string
*/
const AWARENESS_META_KEY = 'wp_sync_awareness';
/**
* Meta key for sync updates.
*
* @since 7.0.0
* @var string
*/
const SYNC_UPDATE_META_KEY = 'wp_sync_update';
/**
* Cache of cursors by room.
*
* @since 7.0.0
* @var array<string, int>
*/
private array $room_cursors = array();
/**
* Cache of update counts by room.
*
* @since 7.0.0
* @var array<string, int>
*/
private array $room_update_counts = array();
/**
* Cache of storage post IDs by room hash.
*
* @since 7.0.0
* @var array<string, int>
*/
private static array $storage_post_ids = array();
/**
* Adds a sync update to a given room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param mixed $update Sync update.
* @return bool True on success, false on failure.
*/
public function add_update( string $room, $update ): bool {
$post_id = $this->get_storage_post_id( $room );
if ( null === $post_id ) {
return false;
}
// Create an envelope and stamp each update to enable cursor-based filtering.
$envelope = array(
'timestamp' => $this->get_time_marker(),
'value' => $update,
);
return (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false );
}
/**
* Retrieves all sync updates for a given room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return array<int, array{ timestamp: int, value: mixed }> Sync updates.
*/
private function get_all_updates( string $room ): array {
$this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency.
$post_id = $this->get_storage_post_id( $room );
if ( null === $post_id ) {
return array();
}
$updates = get_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, false );
if ( ! is_array( $updates ) ) {
$updates = array();
}
// Filter out any updates that don't have the expected structure.
$updates = array_filter(
$updates,
static function ( $update ): bool {
return is_array( $update ) && isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] );
}
);
$this->room_update_counts[ $room ] = count( $updates );
return $updates;
}
/**
* Gets awareness state for a given room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return array<int, mixed> Awareness state.
*/
public function get_awareness_state( string $room ): array {
$post_id = $this->get_storage_post_id( $room );
if ( null === $post_id ) {
return array();
}
$awareness = get_post_meta( $post_id, self::AWARENESS_META_KEY, true );
if ( ! is_array( $awareness ) ) {
return array();
}
return array_values( $awareness );
}
/**
* Sets awareness state for a given room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param array<int, mixed> $awareness Serializable awareness state.
* @return bool True on success, false on failure.
*/
public function set_awareness_state( string $room, array $awareness ): bool {
$post_id = $this->get_storage_post_id( $room );
if ( null === $post_id ) {
return false;
}
// update_post_meta returns false if the value is the same as the existing value.
update_post_meta( $post_id, self::AWARENESS_META_KEY, $awareness );
return true;
}
/**
* Gets the current cursor for a given room.
*
* The cursor is set during get_updates_after_cursor() and represents the
* point in time just before the updates were retrieved, with a small buffer
* to ensure consistency.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return int Current cursor for the room.
*/
public function get_cursor( string $room ): int {
return $this->room_cursors[ $room ] ?? 0;
}
/**
* Gets or creates the storage post for a given room.
*
* Each room gets its own dedicated post so that post meta cache
* invalidation is scoped to a single room rather than all of them.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return int|null Post ID.
*/
private function get_storage_post_id( string $room ): ?int {
$room_hash = md5( $room );
if ( isset( self::$storage_post_ids[ $room_hash ] ) ) {
return self::$storage_post_ids[ $room_hash ];
}
// Try to find an existing post for this room.
$posts = get_posts(
array(
'post_type' => self::POST_TYPE,
'posts_per_page' => 1,
'post_status' => 'publish',
'name' => $room_hash,
'fields' => 'ids',
)
);
$post_id = array_first( $posts );
if ( is_int( $post_id ) ) {
self::$storage_post_ids[ $room_hash ] = $post_id;
return $post_id;
}
// Create new post for this room.
$post_id = wp_insert_post(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'post_title' => 'Sync Storage',
'post_name' => $room_hash,
)
);
if ( is_int( $post_id ) ) {
self::$storage_post_ids[ $room_hash ] = $post_id;
return $post_id;
}
return null;
}
/**
* Gets the current time in milliseconds as a comparable time marker.
*
* @since 7.0.0
*
* @return int Current time in milliseconds.
*/
private function get_time_marker(): int {
return (int) floor( microtime( true ) * 1000 );
}
/**
* Gets the number of updates stored for a given room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return int Number of updates stored for the room.
*/
public function get_update_count( string $room ): int {
return $this->room_update_counts[ $room ] ?? 0;
}
/**
* Retrieves sync updates from a room for a given client and cursor. Updates
* from the specified client should be excluded.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $cursor Return updates after this cursor.
* @return array<int, mixed> Sync updates.
*/
public function get_updates_after_cursor( string $room, int $cursor ): array {
$all_updates = $this->get_all_updates( $room );
$updates = array();
foreach ( $all_updates as $update ) {
if ( $update['timestamp'] > $cursor ) {
$updates[] = $update;
}
}
// Sort by timestamp to ensure order.
usort(
$updates,
fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp']
);
return wp_list_pluck( $updates, 'value' );
}
/**
* Removes updates from a room that are older than the given cursor.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $cursor Remove updates with markers < this cursor.
* @return bool True on success, false on failure.
*/
public function remove_updates_before_cursor( string $room, int $cursor ): bool {
$post_id = $this->get_storage_post_id( $room );
if ( null === $post_id ) {
return false;
}
$all_updates = $this->get_all_updates( $room );
// Remove all updates for the room and re-store only those that are newer than the cursor.
if ( ! delete_post_meta( $post_id, self::SYNC_UPDATE_META_KEY ) ) {
return false;
}
// Re-store envelopes directly to avoid double-wrapping by add_update().
$add_result = true;
foreach ( $all_updates as $envelope ) {
if ( $add_result && $envelope['timestamp'] >= $cursor ) {
$add_result = (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false );
}
}
return $add_result;
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* WP_Sync_Storage interface
*
* @package WordPress
*/
interface WP_Sync_Storage {
/**
* Adds a sync update to a given room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param mixed $update Serializable sync update, opaque to the storage implementation.
* @return bool True on success, false on failure.
*/
public function add_update( string $room, $update ): bool;
/**
* Gets awareness state for a given room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return array<int, mixed> Awareness state.
*/
public function get_awareness_state( string $room ): array;
/**
* Gets the current cursor for a given room. This should return a monotonically
* increasing integer that represents the last update that was returned for the
* room during the current request. This allows clients to retrieve updates
* after a specific cursor on subsequent requests.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return int Current cursor for the room.
*/
public function get_cursor( string $room ): int;
/**
* Gets the total number of stored updates for a given room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return int Total number of updates.
*/
public function get_update_count( string $room ): int;
/**
* Retrieves sync updates from a room for a given client and cursor. Updates
* from the specified client should be excluded.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $cursor Return updates after this cursor.
* @return array<int, mixed> Sync updates.
*/
public function get_updates_after_cursor( string $room, int $cursor ): array;
/**
* Removes updates from a room that are older than the provided cursor.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $cursor Remove updates with markers < this cursor.
* @return bool True on success, false on failure.
*/
public function remove_updates_before_cursor( string $room, int $cursor ): bool;
/**
* Sets awareness state for a given room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param array<int, mixed> $awareness Serializable awareness state.
* @return bool True on success, false on failure.
*/
public function set_awareness_state( string $room, array $awareness ): bool;
}

View File

@@ -786,6 +786,9 @@ add_action( 'deleted_post', '_wp_after_delete_font_family', 10, 2 );
add_action( 'before_delete_post', '_wp_before_delete_font_face', 10, 2 );
add_action( 'init', '_wp_register_default_font_collections' );
// Collaboration.
add_action( 'admin_init', 'wp_collaboration_inject_setting' );
// Add ignoredHookedBlocks metadata attribute to the template and template part post types.
add_filter( 'rest_pre_insert_wp_template', 'inject_ignored_hooked_blocks_metadata_attributes' );
add_filter( 'rest_pre_insert_wp_template_part', 'inject_ignored_hooked_blocks_metadata_attributes' );

View File

@@ -2897,6 +2897,18 @@ function register_initial_settings() {
)
);
register_setting(
'writing',
'enable_real_time_collaboration',
array(
'type' => 'boolean',
'description' => __( 'Enable Real-Time Collaboration' ),
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => true,
'show_in_rest' => true,
)
);
register_setting(
'reading',
'posts_per_page',

View File

@@ -657,6 +657,41 @@ function create_initial_post_types() {
)
);
if ( get_option( 'enable_real_time_collaboration' ) ) {
register_post_type(
'wp_sync_storage',
array(
'labels' => array(
'name' => __( 'Sync Updates' ),
'singular_name' => __( 'Sync Update' ),
),
'public' => false,
'_builtin' => true, /* internal use only. don't use this when registering your own post type. */
'hierarchical' => false,
'capabilities' => array(
'read' => 'do_not_allow',
'read_private_posts' => 'do_not_allow',
'create_posts' => 'do_not_allow',
'publish_posts' => 'do_not_allow',
'edit_posts' => 'do_not_allow',
'edit_others_posts' => 'do_not_allow',
'edit_published_posts' => 'do_not_allow',
'delete_posts' => 'do_not_allow',
'delete_others_posts' => 'do_not_allow',
'delete_published_posts' => 'do_not_allow',
),
'map_meta_cap' => false,
'publicly_queryable' => false,
'query_var' => false,
'rewrite' => false,
'show_in_menu' => false,
'show_in_rest' => false,
'show_ui' => false,
'supports' => array( 'custom-fields' ),
)
);
}
register_post_status(
'publish',
array(
@@ -8611,6 +8646,7 @@ function use_block_editor_for_post_type( $post_type ) {
* Registers any additional post meta fields.
*
* @since 6.3.0 Adds `wp_pattern_sync_status` meta field to the wp_block post type so an unsynced option can be added.
* @since 7.0.0 Adds `_crdt_document` meta field to post types so that CRDT documents can be persisted.
*
* @link https://github.com/WordPress/gutenberg/pull/51144
*/
@@ -8630,4 +8666,30 @@ function wp_create_initial_post_meta() {
),
)
);
if ( get_option( 'enable_real_time_collaboration' ) ) {
register_meta(
'post',
'_crdt_document',
array(
'auth_callback' => static function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool {
return user_can( $user_id, 'edit_post', $object_id );
},
/*
* Revisions must be disabled because we always want to preserve
* the latest persisted CRDT document, even when a revision is restored.
* This ensures that we can continue to apply updates to a shared document
* and peers can simply merge the restored revision like any other incoming
* update.
*
* If we want to persist CRDT documents alongside revisions in the
* future, we should do so in a separate meta key.
*/
'revisions_enabled' => false,
'show_in_rest' => true,
'single' => true,
'type' => 'string',
)
);
}
}

View File

@@ -428,6 +428,13 @@ function create_initial_rest_routes() {
// Icons.
$icons_controller = new WP_REST_Icons_Controller();
$icons_controller->register_routes();
// Collaboration.
if ( get_option( 'enable_real_time_collaboration' ) ) {
$sync_storage = new WP_Sync_Post_Meta_Storage();
$sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage );
$sync_server->register_routes();
}
}
/**

View File

@@ -232,7 +232,31 @@ class WP_REST_Autosaves_Controller extends WP_REST_Revisions_Controller {
$post_lock = wp_check_post_lock( $post->ID );
$is_draft = 'draft' === $post->post_status || 'auto-draft' === $post->post_status;
if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock ) {
/*
* In the context of real-time collaboration, all peers are effectively
* authors and we don't want to vary behavior based on whether they are the
* original author. Always target an autosave revision.
*
* This avoids the following issue when real-time collaboration is enabled:
*
* - Autosaves from the original author (if they have the post lock) will
* target the saved post.
*
* - Autosaves from other users are applied to a post revision.
*
* - If any user reloads a post, they load changes from the author's autosave.
*
* - The saved post has now diverged from the persisted CRDT document. The
* content (and/or title or excerpt) are now "ahead" of the persisted CRDT
* document.
*
* - When the persisted CRDT document is loaded, a diff is computed against
* the saved post. This diff is then applied to the in-memory CRDT
* document, which can lead to duplicate inserts or deletions.
*/
$is_collaboration_enabled = get_option( 'enable_real_time_collaboration' );
if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock && ! $is_collaboration_enabled ) {
/*
* Draft posts for the same author: autosaving updates the post and does not create a revision.
* Convert the post object to an array and add slashes, wp_update_post() expects escaped array.

View File

@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
$wp_version = '7.0-alpha-61688';
$wp_version = '7.0-alpha-61689';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.

View File

@@ -300,6 +300,10 @@ require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php';
require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php';
require ABSPATH . WPINC . '/abilities-api.php';
require ABSPATH . WPINC . '/abilities.php';
require ABSPATH . WPINC . '/collaboration/interface-wp-sync-storage.php';
require ABSPATH . WPINC . '/collaboration/class-wp-sync-post-meta-storage.php';
require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php';
require ABSPATH . WPINC . '/collaboration.php';
require ABSPATH . WPINC . '/rest-api.php';
require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php';
require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php';