diff --git a/ghost/admin/app/components/gh-koenig-editor-lexical.hbs b/ghost/admin/app/components/gh-koenig-editor-lexical.hbs index d0a5a39afe..38a52d3fab 100644 --- a/ghost/admin/app/components/gh-koenig-editor-lexical.hbs +++ b/ghost/admin/app/components/gh-koenig-editor-lexical.hbs @@ -38,6 +38,7 @@ { export default class KoenigLexicalEditor extends Component { @service ajax; @service feature; + @service ghostPaths; + @service session; @inject config; @@ -145,7 +147,7 @@ export default class KoenigLexicalEditor extends Component { } ReactComponent = () => { - const cardConfig = { + const defaultCardConfig = { unsplash: { defaultHeaders: { Authorization: `Client-ID 8672af113b0a8573edae3aa3713886265d9bb741d707f6c01a486cde8c278980`, @@ -157,6 +159,7 @@ export default class KoenigLexicalEditor extends Component { }, tenor: this.config.tenor?.googleApiKey ? this.config.tenor : null }; + const cardConfig = Object.assign({}, defaultCardConfig, this.args.cardConfig); const useFileUpload = (type = 'image') => { const [progress, setProgress] = React.useState(0); @@ -350,21 +353,35 @@ export default class KoenigLexicalEditor extends Component { return {progress, isLoading, upload, errors, filesNumber}; }; + // TODO: react component isn't re-rendered when its props are changed meaning we don't transition + // to enabling multiplayer when a new post is saved and it gets an ID we can use for a YJS doc + // - figure out how to re-render the component when its props change + // - figure out some other mechanism for handling posts that don't really exist yet with multiplayer + const enableMultiplayer = this.feature.lexicalMultiplayer && !cardConfig.post.isNew; + const multiplayerWsProtocol = window.location.protocol === 'https:' ? `wss: ` : `ws:`; + const multiplayerEndpoint = multiplayerWsProtocol + window.location.host + this.ghostPaths.url.api('posts', 'multiplayer'); + const multiplayerDocId = cardConfig.post.id; + const multiplayerUsername = this.session.user.name; + return (
Loading editor...

}>
diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js index 0f8b89a2e7..c8ab948c7c 100644 --- a/ghost/admin/app/services/feature.js +++ b/ghost/admin/app/services/feature.js @@ -63,6 +63,7 @@ export default class FeatureService extends Service { @feature('memberAttribution') memberAttribution; @feature('sourceAttribution') sourceAttribution; @feature('lexicalEditor') lexicalEditor; + @feature('lexicalMultiplayer') lexicalMultiplayer; @feature('audienceFeedback') audienceFeedback; @feature('suppressionList') suppressionList; @feature('webmentions') webmentions; diff --git a/ghost/admin/app/templates/settings/labs.hbs b/ghost/admin/app/templates/settings/labs.hbs index 9852c6102f..2b2ce3fd0b 100644 --- a/ghost/admin/app/templates/settings/labs.hbs +++ b/ghost/admin/app/templates/settings/labs.hbs @@ -214,6 +214,19 @@
+
+
+
+

Lexical multiplayer

+

+ Enables multiplayer editing in the lexical editor. +

+
+
+ +
+
+
diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index ebc1a2d0b2..ac3bc46f09 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -509,6 +509,10 @@ async function bootGhost({backend = true, frontend = true, server = true} = {}) // NOTE: changes in this labs setting requires server reboot since we don't re-init services after changes a labs flag const websockets = require('./server/services/websockets'); await websockets.init(ghostServer); + + const lexicalMultiplayer = require('./server/services/lexical-multiplayer'); + await lexicalMultiplayer.init(ghostServer); + await lexicalMultiplayer.enable(); } await initServices({config}); diff --git a/ghost/core/core/server/api/endpoints/settings.js b/ghost/core/core/server/api/endpoints/settings.js index 6286c6ac6c..0335aa0091 100644 --- a/ghost/core/core/server/api/endpoints/settings.js +++ b/ghost/core/core/server/api/endpoints/settings.js @@ -63,7 +63,7 @@ module.exports = { ], async query(frame) { await settingsBREADService.verifyKeyUpdate(frame.data.token); - + // We need to return all settings here, because we have calculated settings that might change const browse = await settingsBREADService.browse(frame.options.context); diff --git a/ghost/core/core/server/services/lexical-multiplayer/index.js b/ghost/core/core/server/services/lexical-multiplayer/index.js new file mode 100644 index 0000000000..102ef66d4f --- /dev/null +++ b/ghost/core/core/server/services/lexical-multiplayer/index.js @@ -0,0 +1 @@ +module.exports = require('./service'); diff --git a/ghost/core/core/server/services/lexical-multiplayer/service.js b/ghost/core/core/server/services/lexical-multiplayer/service.js new file mode 100644 index 0000000000..e0b3de27ad --- /dev/null +++ b/ghost/core/core/server/services/lexical-multiplayer/service.js @@ -0,0 +1,141 @@ +const debug = require('@tryghost/debug')('lexical-multiplayer'); // eslint-disable-line no-unused-vars +const logging = require('@tryghost/logging'); +const {getSession} = require('../auth/session/express-session'); +const models = require('../../models'); +const labs = require('../../../shared/labs'); + +let wss; + +const onSocketError = (error) => { + logging.error(error); +}; + +const onUnauthorized = (socket) => { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); +}; + +const handleUpgrade = async (request, socket, head) => { + socket.on('error', onSocketError); + + // make sure the request is on the supported path + // TODO: check handling of subdirectories + if (!request.url.startsWith('/ghost/api/admin/posts/multiplayer/')) { + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + socket.destroy(); + return; + } + + // grab the session from the request + const session = await getSession(request, {}); + if (!session || !session.user_id) { + return onUnauthorized(socket); + } + + // fetch the session's user from the db + const user = await models.User.findOne({id: session.user_id}); + if (!user) { + return onUnauthorized(socket); + } + request.user = user; + + // TODO: check if user has access to the post + + // TODO: (elsewhere) close websocket connections on logout + // - probably need to create a map of sockets to users? + + socket.removeListener('error', onSocketError); + + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + }); +}; + +let _enable; +let _disable; +let _isClosing = false; +let _closePromise; + +module.exports = { + async init(ghostServer) { + _enable = async () => { + if (_isClosing) { + logging.info('Waiting for previous Lexical multiplayer websockets service to close'); + await _closePromise; + } + + if (wss) { + logging.info('Lexical multiplayer websockets service already started'); + return; + } + + if (labs.isSet('lexicalMultiplayer')) { + logging.info('Starting lexical multiplayer websockets service'); + + // TODO: can we use or adapt patterns from https://github.com/HenningM/express-ws? + const WS = require('ws'); + wss = new WS.Server({noServer: true}); + const {setupWSConnection} = require('./y-websocket'); + + wss.on('connection', (socket, request) => { + socket.on('error', onSocketError); + + // TODO: better method for extracting doc name from URL + const docName = request.url.replace('/ghost/api/admin/posts/multiplayer/', ''); + setupWSConnection(socket, request, {docName}); + }); + + // TODO: this should probably be at a higher level, especially if we + // want to support multiple websocket services + ghostServer.httpServer.on('upgrade', handleUpgrade); + } + }; + + _disable = async () => { + logging.info('Stopping lexical multiplayer websockets service'); + ghostServer.httpServer.off('upgrade', handleUpgrade); + + if (wss) { + _isClosing = true; + _closePromise = new Promise((resolve) => { + // first sweep, soft close + wss.clients.forEach((socket) => { + socket.close(); + }); + + setTimeout(() => { + // second sweep, hard close + wss.clients.forEach((socket) => { + if ([socket.OPEN, socket.CLOSING].includes(socket.readyState)) { + socket.terminate(); + } + }); + + resolve(); + }, 5000); + }).finally(() => { + wss = null; + _isClosing = false; + }); + + return _closePromise; + } + }; + }, + + async enable() { + if (!_enable) { + logging.error('Lexical multiplayer service must be initialized before it can be enabled/disabled'); + return; + } + return _enable(); + }, + + async disable() { + if (!_enable) { + logging.error('Lexical multiplayer service must be initialized before it can be enabled/disabled'); + return; + } + return _disable(); + } +}; diff --git a/ghost/core/core/server/services/lexical-multiplayer/y-websocket.js b/ghost/core/core/server/services/lexical-multiplayer/y-websocket.js new file mode 100644 index 0000000000..b376e3cec4 --- /dev/null +++ b/ghost/core/core/server/services/lexical-multiplayer/y-websocket.js @@ -0,0 +1,244 @@ +// based on https://github.com/yjs/y-websocket/blob/master/bin/utils.js + +const Y = require('yjs'); +const syncProtocol = require('y-protocols/dist/sync.cjs'); +const awarenessProtocol = require('y-protocols/dist/awareness.cjs'); + +const encoding = require('lib0/dist/encoding.cjs'); +const decoding = require('lib0/dist/decoding.cjs'); +const map = require('lib0/dist/map.cjs'); + +const wsReadyStateConnecting = 0; +const wsReadyStateOpen = 1; +const wsReadyStateClosing = 2 // eslint-disable-line +const wsReadyStateClosed = 3 // eslint-disable-line + +/** + * @type {Map} + */ +const docs = new Map(); +module.exports.docs = docs; + +const messageSync = 0; +const messageAwareness = 1; + +/** + * @param {Uint8Array} update + * @param {any} origin + * @param {WSSharedDoc} doc + */ +const updateHandler = (update, origin, doc) => { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageSync); + syncProtocol.writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + doc.conns.forEach((_, conn) => send(doc, conn, message)); +}; + +class WSSharedDoc extends Y.Doc { + /** + * @param {string} name + */ + constructor(name) { + super({gc: true}); + this.name = name; + /** + * Maps from conn to set of controlled user ids. Delete all user ids from awareness when this conn is closed + * @type {Map>} + */ + this.conns = new Map(); + /** + * @type {awarenessProtocol.Awareness} + */ + this.awareness = new awarenessProtocol.Awareness(this); + this.awareness.setLocalState(null); + /** + * @param {{ added: Array, updated: Array, removed: Array }} changes + * @param {Object | null} conn Origin is the connection that made the change + */ + const awarenessChangeHandler = ({added, updated, removed}, conn) => { + const changedClients = added.concat(updated, removed); + if (conn !== null) { + const connControlledIDs = /** @type {Set} */ (this.conns.get(conn)); + if (connControlledIDs !== undefined) { + added.forEach((clientID) => { + connControlledIDs.add(clientID); + }); + removed.forEach((clientID) => { + connControlledIDs.delete(clientID); + }); + } + } + // broadcast awareness update + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageAwareness); + encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients)); + const buff = encoding.toUint8Array(encoder); + this.conns.forEach((_, c) => { + send(this, c, buff); + }); + }; + this.awareness.on('update', awarenessChangeHandler); + this.on('update', updateHandler); + // if (isCallbackSet) { + // this.on('update', debounce( + // callbackHandler, + // CALLBACK_DEBOUNCE_WAIT, + // { maxWait: CALLBACK_DEBOUNCE_MAXWAIT } + // )) + // } + } +} + +/** + * Gets a Y.Doc by name, whether in memory or on disk + * + * @param {string} docname - the name of the Y.Doc to find or create + * @param {boolean} gc - whether to allow gc on the doc (applies only when created) + * @return {WSSharedDoc} + */ +const getYDoc = (docname, gc = true) => map.setIfUndefined(docs, docname, () => { + const doc = new WSSharedDoc(docname); + doc.gc = gc; + // if (persistence !== null) { + // persistence.bindState(docname, doc); + // } + docs.set(docname, doc); + return doc; +}); + +module.exports.getYDoc = getYDoc; + +/** + * @param {any} conn + * @param {WSSharedDoc} doc + * @param {Uint8Array} message + */ +const messageListener = (conn, doc, message) => { + try { + const encoder = encoding.createEncoder(); + const decoder = decoding.createDecoder(message); + const messageType = decoding.readVarUint(decoder); + switch (messageType) { + case messageSync: + encoding.writeVarUint(encoder, messageSync); + syncProtocol.readSyncMessage(decoder, encoder, doc, conn); + + // If the `encoder` only contains the type of reply message and no + // message, there is no need to send the message. When `encoder` only + // contains the type of reply, its length is 1. + if (encoding.length(encoder) > 1) { + send(doc, conn, encoding.toUint8Array(encoder)); + } + break; + case messageAwareness: { + awarenessProtocol.applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn); + break; + } + } + } catch (err) { + doc.emit('error', [err]); + } +}; + +/** + * @param {WSSharedDoc} doc + * @param {any} conn + */ +const closeConn = (doc, conn) => { + if (doc.conns.has(conn)) { + /** + * @type {Set} + */ + const controlledIds = doc.conns.get(conn); + doc.conns.delete(conn); + awarenessProtocol.removeAwarenessStates(doc.awareness, Array.from(controlledIds), null); + if (doc.conns.size === 0/* && persistence !== null*/) { + // if persisted, we store state and destroy ydocument + // persistence.writeState(doc.name, doc).then(() => { + // doc.destroy(); + // }); + docs.delete(doc.name); + } + } + conn.close(); +}; + +/** + * @param {WSSharedDoc} doc + * @param {any} conn + * @param {Uint8Array} m + */ +const send = (doc, conn, m) => { + if (conn.readyState !== wsReadyStateConnecting && conn.readyState !== wsReadyStateOpen) { + closeConn(doc, conn); + } + try { + conn.send(m, (err) => { + if (err !== null && err !== undefined) { + closeConn(doc, conn); + } + }); + } catch (e) { + closeConn(doc, conn); + } +}; + +const pingTimeout = 30000; + +/** + * @param {any} conn + * @param {any} req + * @param {any} opts + */ +module.exports.setupWSConnection = (conn, req, {docName = req.url.slice(1).split('?')[0], gc = true} = {}) => { + conn.binaryType = 'arraybuffer'; + // get doc, initialize if it does not exist yet + const doc = getYDoc(docName, gc); + doc.conns.set(conn, new Set()); + // listen and reply to events + conn.on('message', /** @param {ArrayBuffer} message */ message => messageListener(conn, doc, new Uint8Array(message))); + + // Check if connection is still alive + let pongReceived = true; + const pingInterval = setInterval(() => { + if (!pongReceived) { + if (doc.conns.has(conn)) { + closeConn(doc, conn); + } + clearInterval(pingInterval); + } else if (doc.conns.has(conn)) { + pongReceived = false; + try { + conn.ping(); + } catch (e) { + closeConn(doc, conn); + clearInterval(pingInterval); + } + } + }, pingTimeout); + conn.on('close', () => { + closeConn(doc, conn); + clearInterval(pingInterval); + }); + conn.on('pong', () => { + pongReceived = true; + }); + // put the following in a variables in a block so the interval handlers don't keep in in + // scope + { + // send sync step 1 + const syncEncoder = encoding.createEncoder(); + encoding.writeVarUint(syncEncoder, messageSync); + syncProtocol.writeSyncStep1(syncEncoder, doc); + send(doc, conn, encoding.toUint8Array(syncEncoder)); + + const awarenessStates = doc.awareness.getStates(); + if (awarenessStates.size > 0) { + const awarenessEncoder = encoding.createEncoder(); + encoding.writeVarUint(awarenessEncoder, messageAwareness); + encoding.writeVarUint8Array(awarenessEncoder, awarenessProtocol.encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys()))); + send(doc, conn, encoding.toUint8Array(awarenessEncoder)); + } + } +}; diff --git a/ghost/core/core/server/services/settings/settings-bread-service.js b/ghost/core/core/server/services/settings/settings-bread-service.js index e2e138d169..08c67834a7 100644 --- a/ghost/core/core/server/services/settings/settings-bread-service.js +++ b/ghost/core/core/server/services/settings/settings-bread-service.js @@ -214,6 +214,21 @@ class SettingsBREADService { const {filteredSettings: refilteredSettings, emailsToVerify} = await this.prepSettingsForEmailVerification(filteredSettings, getSetting); const modelArray = await this.SettingsModel.edit(refilteredSettings, options).then((result) => { + // TODO: temporary fix for starting/stopping lexicalMultiplayer service when labs flag is changed + // this should be removed along with the flag, or set up in a more generic way + const labsSetting = result.find(setting => setting.get('key') === 'labs'); + if (labsSetting) { + const lexicalMultiplayer = require('../lexical-multiplayer'); + const previous = JSON.parse(labsSetting.previousAttributes().value); + const current = JSON.parse(labsSetting.get('value')); + + if (!previous.lexicalMultiplayer && current.lexicalMultiplayer) { + lexicalMultiplayer.enable(); + } else if (previous.lexicalMultiplayer && !current.lexicalMultiplayer) { + lexicalMultiplayer.disable(); + } + } + return this._formatBrowse(_.keyBy(_.invokeMap(result, 'toJSON'), 'key'), options.context); }); diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 6e9e83f112..4c34811b31 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -36,6 +36,7 @@ const ALPHA_FEATURES = [ 'urlCache', 'migrateApp', 'lexicalEditor', + 'lexicalMultiplayer', 'websockets', 'stripeAutomaticTax', 'makingItRain' diff --git a/ghost/core/package.json b/ghost/core/package.json index fbd2134428..52ca79e4ed 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -191,6 +191,7 @@ "keypair": "1.0.4", "knex": "2.4.2", "knex-migrator": "5.1.3", + "lib0": "0.2.73", "lodash": "4.17.21", "luxon": "3.2.1", "moment": "2.24.0", @@ -208,7 +209,10 @@ "socket.io": "4.6.1", "stoppable": "1.1.0", "uuid": "9.0.0", - "xml": "1.0.1" + "ws": "8.13.0", + "xml": "1.0.1", + "y-protocols": "1.0.5", + "yjs": "13.5.50" }, "optionalDependencies": { "@tryghost/html-to-mobiledoc": "2.0.11", diff --git a/yarn.lock b/yarn.lock index 493eb0d8db..f4885eeee1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18856,6 +18856,11 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== +isomorphic.js@^0.2.4: + version "0.2.5" + resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz#13eecf36f2dba53e85d355e11bf9d4208c6f7f88" + integrity sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw== + isostring@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/isostring/-/isostring-0.0.1.tgz#ddb608efbfc89cda86db9cb16be090a788134c7f" @@ -20325,6 +20330,13 @@ lexical@^0.9.0: resolved "https://registry.yarnpkg.com/lexical/-/lexical-0.9.0.tgz#9c67b624b19a521b2515721b65a12e7fdc806980" integrity sha512-UHMvjRVqrpBLJkCRytVxAvy3PftRnlhZIqKcY1Uiugf6KjzP0GQTwWhxxcltJJ0sZCjZcVYgiib+m08R5KXz0g== +lib0@0.2.73, lib0@^0.2.42, lib0@^0.2.49: + version "0.2.73" + resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.73.tgz#af7d7ce9ad523fa3e241d437cc3ab1862f9a1f29" + integrity sha512-aJJIElCLWnHMcYZPtsM07QoSfHwpxCy4VUzBYGXFYEmh/h2QS5uZNbCCfL0CqnkOE30b7Tp9DVfjXag+3qzZjQ== + dependencies: + isomorphic.js "^0.2.4" + libnpmaccess@6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/libnpmaccess/-/libnpmaccess-6.0.3.tgz#473cc3e4aadb2bc713419d92e45d23b070d8cded" @@ -31015,16 +31027,16 @@ write-pkg@4.0.0: type-fest "^0.4.1" write-json-file "^3.2.0" +ws@8.13.0, ws@^8.13.0: + version "8.13.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" + integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== + ws@^7.4.6: version "7.5.9" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== -ws@^8.13.0: - version "8.13.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" - integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== - ws@~7.4.2: version "7.4.6" resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" @@ -31075,6 +31087,13 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== +y-protocols@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.5.tgz#91d574250060b29fcac8f8eb5e276fbad594245e" + integrity sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A== + dependencies: + lib0 "^0.2.42" + y18n@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" @@ -31206,6 +31225,13 @@ yeast@0.1.2: resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" integrity sha512-8HFIh676uyGYP6wP13R/j6OJ/1HwJ46snpvzE7aHAN3Ryqh2yX6Xox2B4CUmTwwOIzlG3Bs7ocsP5dZH/R1Qbg== +yjs@13.5.50: + version "13.5.50" + resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.5.50.tgz#ab0605c677922163c9fe49295d3fd47c04c8e0e9" + integrity sha512-Q2KVNfovwjtJV4Yxz+HaFYT6vTYBaFagOSpTL3jbPc7Sbv/My68fLTfPlYy9FmNO87pV8dMBd5XuVar+9WsAWg== + dependencies: + lib0 "^0.2.49" + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"