diff --git a/examples/monaco/package.json b/examples/monaco/package.json index dfa4442..e741fe3 100644 --- a/examples/monaco/package.json +++ b/examples/monaco/package.json @@ -16,16 +16,17 @@ "devDependencies": { "@rocicorp/eslint-config": "^0.5.1", "@rocicorp/prettier-config": "^0.2.0", - "@rocicorp/reflect-yjs": "0.0.1", "@rocicorp/reflect": "^0.38.202311200859", - "@types/react-dom": "^18.2.17", + "@rocicorp/reflect-yjs": "0.0.1", "@types/react": "^18.2.38", + "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.0", + "badwords-list": "^1.0.0", "concurrently": "^8.2.2", "monaco-editor": "^0.44.0", "nanoid": "^5.0.3", - "react-dom": "^18.2.0", "react": "^18.2.0", + "react-dom": "^18.2.0", "typescript": "^5.3.2", "vite": "^5.0.2", "y-monaco": "^0.1.5" diff --git a/examples/monaco/src/index.ts b/examples/monaco/src/index.ts index d37f56f..d5c160c 100644 --- a/examples/monaco/src/index.ts +++ b/examples/monaco/src/index.ts @@ -29,7 +29,6 @@ const reflect = new Reflect({ server, userID, roomID, - auth: userID, mutators, }); diff --git a/examples/monaco/src/reflect/index.ts b/examples/monaco/src/reflect/index.ts index bf547df..523d5e0 100644 --- a/examples/monaco/src/reflect/index.ts +++ b/examples/monaco/src/reflect/index.ts @@ -1,27 +1,24 @@ -import type {AuthHandler, ReflectServerOptions} from '@rocicorp/reflect/server'; -import {mutators as yjsMutators, Mutators} from '@rocicorp/reflect-yjs'; +import {mutators as yjsMutators, updateYJS} from '@rocicorp/reflect-yjs'; +// @ts-expect-error "no ts support" +import {regex} from 'badwords-list'; -const authHandler: AuthHandler = (auth: string, _roomID: string) => { - if (auth) { - // A real implementation could: - // 1. if using session auth make a fetch call to a service to - // look up the userID by `auth` in a session database. - // 2. if using stateless JSON Web Token auth, decrypt and validate the token - // and return the sub field value for userID (i.e. subject field). - // It should also check that the user with userID is authorized - // to access the room with roomID. - return { - userID: auth, - }; - } - return null; -}; - -function makeOptions(): ReflectServerOptions { +function makeOptions() { return { - mutators: yjsMutators, - authHandler, - logLevel: 'debug', + mutators: { + ...yjsMutators, + updateYJS: updateYJS({ + validator: doc => { + const text = doc.getText('monaco'); + const string = text.toString(); + let match: RegExpExecArray | null = null; + while ((match = regex.exec(string)) !== null) { + const badWordLength = match[0].length; + text.delete(match.index, badWordLength); + text.insert(match.index, '*'.repeat(badWordLength)); + } + }, + }), + }, }; } diff --git a/package-lock.json b/package-lock.json index 58b1639..9e53ba7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "@types/react": "^18.2.38", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.0", + "badwords-list": "^1.0.0", "concurrently": "^8.2.2", "monaco-editor": "^0.44.0", "nanoid": "^5.0.3", @@ -3828,6 +3829,12 @@ "node": "*" } }, + "node_modules/badwords-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-1.0.0.tgz", + "integrity": "sha512-oWhaSG67e+HQj3OGHQt2ucP+vAPm1wTbdp2aDHeuh4xlGXBdWwzZ//pfu6swf5gZ8iX0b7JgmSo8BhgybbqszA==", + "dev": true + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -10296,6 +10303,12 @@ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true }, + "badwords-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-1.0.0.tgz", + "integrity": "sha512-oWhaSG67e+HQj3OGHQt2ucP+vAPm1wTbdp2aDHeuh4xlGXBdWwzZ//pfu6swf5gZ8iX0b7JgmSo8BhgybbqszA==", + "dev": true + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -12314,6 +12327,7 @@ "@types/react": "^18.2.38", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.0", + "badwords-list": "*", "concurrently": "^8.2.2", "monaco-editor": "^0.44.0", "nanoid": "^5.0.3", diff --git a/packages/reflect-yjs/src/index.ts b/packages/reflect-yjs/src/index.ts index fcb490a..00c3704 100644 --- a/packages/reflect-yjs/src/index.ts +++ b/packages/reflect-yjs/src/index.ts @@ -1,3 +1,3 @@ export {Awareness} from './awareness.js'; -export {mutators, type Mutators} from './mutators.js'; +export {mutators, type Mutators, updateYJS} from './mutators.js'; export {Provider} from './provider.js'; diff --git a/packages/reflect-yjs/src/mutators.ts b/packages/reflect-yjs/src/mutators.ts index 3d563a3..eb8631a 100644 --- a/packages/reflect-yjs/src/mutators.ts +++ b/packages/reflect-yjs/src/mutators.ts @@ -12,27 +12,48 @@ import {chunk, unchunk} from './chunk.js'; export const mutators = { yjsSetLocalStateField, yjsSetLocalState, - updateYJS, + updateYJS: updateYJS(undefined), }; export type Mutators = typeof mutators; -export async function updateYJS( - tx: WriteTransaction, - {name, update}: {name: string; update: string}, -) { - if (tx.location === 'server') { - const existingServerUpdate = await getServerUpdate(name, tx); - if (!existingServerUpdate) { - await setServerUpdate(name, base64.toByteArray(update), tx); - } else { - const updates = [existingServerUpdate, base64.toByteArray(update)]; - const merged = Y.mergeUpdatesV2(updates); +export type UpdateYJSArgs = { + validator?: ((doc: Y.Doc) => void) | undefined; +}; + +export function updateYJS(args?: UpdateYJSArgs | undefined) { + return async function ( + tx: WriteTransaction, + {name, update}: {name: string; update: string}, + ) { + const {validator} = args ?? {}; + if (tx.location === 'server') { + const existingServerUpdate = await getServerUpdate(name, tx); + const decodedUpdate = base64.toByteArray(update); + let merged = existingServerUpdate + ? Y.mergeUpdatesV2([existingServerUpdate, decodedUpdate]) + : decodedUpdate; + + if (validator) { + // If we have a validator, we need to materialize the doc. + // This is slow, but we'll add features to Reflect in the future to keep this doc + // loaded so we don't have to do it over and over. Currently we cannot because it is + // possible for multiple rooms to be loaded into the same JS context, so global + // variables don't work. We need some shared context that we can stash cross-mutator + // state like this on. + const doc = new Y.Doc(); + Y.applyUpdateV2(doc, merged); + validator(doc); + merged = Y.encodeStateAsUpdateV2(doc); + } await setServerUpdate(name, merged, tx); + } else { + if (validator) { + throw new Error('validator only supported on server'); + } + await setClientUpdate(name, update, tx); } - } else { - await setClientUpdate(name, update, tx); - } + }; } export function yjsProviderKeyPrefix(name: string): string { diff --git a/packages/reflect-yjs/src/provider.test.ts b/packages/reflect-yjs/src/provider.test.ts index eeb5cc7..34539ba 100644 --- a/packages/reflect-yjs/src/provider.test.ts +++ b/packages/reflect-yjs/src/provider.test.ts @@ -29,7 +29,7 @@ class FakeReflect { .fn() .mockReturnValue(() => undefined); mutate: { - updateYJS: MockedMutator; + updateYJS: MockedMutator>; yjsSetLocalStateField: MockedMutator; yjsSetLocalState: MockedMutator; } = {