├── .prettierrc ├── renovate.json ├── .gitignore ├── src ├── index.ts ├── server.ts └── provider.ts ├── eslint.config.mjs ├── .editorconfig ├── playground ├── wrangler.toml ├── public │ ├── index.html │ ├── tiptap │ │ ├── index.js │ │ ├── editor.js │ │ ├── style.css │ │ ├── index.html │ │ └── theme.css │ └── prosemirror │ │ ├── index.js │ │ ├── index.html │ │ ├── schema.js │ │ └── style.css ├── bun.ts ├── node.ts ├── deno.ts └── cf.ts ├── tsconfig.json ├── .github └── workflows │ ├── ci.yml │ ├── autofix.yml │ └── deploy.yml ├── LICENSE ├── CHANGELOG.md ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>unjs/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .vscode 5 | .DS_Store 6 | .eslintcache 7 | *.log* 8 | *.env* 9 | .wrangler 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Server 2 | export { createHandler } from "./server.ts"; 3 | export type { YCrosswsHandler } from "./server.ts"; 4 | 5 | // Provider 6 | export { WebsocketProvider } from "./provider.ts"; 7 | export type { WebsocketProviderOptions } from "./provider.ts"; 8 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import unjs from "eslint-config-unjs"; 2 | 3 | export default unjs({ 4 | ignores: [], 5 | rules: { 6 | "unicorn/no-null": 0, 7 | "@typescript-eslint/no-empty-object-type": 0, 8 | "@typescript-eslint/no-non-null-asserted-optional-chain": 0, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{package.json,*.yml,*.cjson}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /playground/wrangler.toml: -------------------------------------------------------------------------------- 1 | compatibility_date = "2024-09-25" 2 | 3 | name = "y-crossws" 4 | 5 | site = { bucket = "./public" } 6 | 7 | main = "./cf.ts" 8 | 9 | durable_objects.bindings = [{ name = "$DurableObject", class_name = "$DurableObject" }] 10 | 11 | migrations = [{ tag = "v1", new_classes = ["$DurableObject"] }] 12 | -------------------------------------------------------------------------------- /playground/public/index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |Hi 👋, this is a collaborative document.
26 |Feel free to edit and collaborate in real-time!
27 | `; 28 | 29 | const getInitialUser = () => { 30 | return { 31 | name: faker.person.fullName(), 32 | color: colors[Math.floor(Math.random() * colors.length)], 33 | }; 34 | }; 35 | 36 | const Editor = ({ ydoc, provider, room }) => { 37 | const [status, setStatus] = useState("connecting"); 38 | const [currentUser, setCurrentUser] = useState(getInitialUser); 39 | 40 | const editor = useEditor({ 41 | onCreate: ({ editor: currentEditor }) => { 42 | provider.on("synced", () => { 43 | if (currentEditor.isEmpty) { 44 | currentEditor.commands.setContent(defaultContent); 45 | } 46 | }); 47 | }, 48 | extensions: [ 49 | StarterKit.configure({ history: false }), 50 | Highlight, 51 | TaskList, 52 | TaskItem, 53 | CharacterCount.configure({ limit: 10_000 }), 54 | Collaboration.configure({ document: ydoc }), 55 | CollaborationCursor.configure({ provider }), 56 | ], 57 | }); 58 | 59 | useEffect(() => { 60 | // Update status changes 61 | const statusHandler = (event) => { 62 | setStatus(event.status); 63 | }; 64 | provider.on("status", statusHandler); 65 | return () => { 66 | provider.off("status", statusHandler); 67 | }; 68 | }, [provider]); 69 | 70 | // Save current user to localStorage and emit to editor 71 | useEffect(() => { 72 | if (editor && currentUser) { 73 | localStorage.setItem("currentUser", JSON.stringify(currentUser)); 74 | editor.chain().focus().updateUser(currentUser).run(); 75 | } 76 | }, [editor, currentUser]); 77 | 78 | const setName = useCallback(() => { 79 | const name = (window.prompt("Name", currentUser.name) || "") 80 | .trim() 81 | .slice(0, 32); 82 | if (name) { 83 | return setCurrentUser({ 84 | ...currentUser, 85 | name, 86 | }); 87 | } 88 | }, [currentUser]); 89 | 90 | if (!editor) { 91 | return null; 92 | } 93 | 94 | const menu = h( 95 | React.Fragment, 96 | null, 97 | editor && 98 | h( 99 | BubbleMenu, 100 | { 101 | className: "bubble-menu", 102 | tippyOptions: { 103 | duration: 100, 104 | }, 105 | editor: editor, 106 | }, 107 | h( 108 | "button", 109 | { 110 | onClick: () => editor.chain().focus().toggleBold().run(), 111 | className: editor.isActive("bold") ? "is-active" : "", 112 | }, 113 | "Bold", 114 | ), 115 | h( 116 | "button", 117 | { 118 | onClick: () => editor.chain().focus().toggleItalic().run(), 119 | className: editor.isActive("italic") ? "is-active" : "", 120 | }, 121 | "Italic", 122 | ), 123 | h( 124 | "button", 125 | { 126 | onClick: () => editor.chain().focus().toggleStrike().run(), 127 | className: editor.isActive("strike") ? "is-active" : "", 128 | }, 129 | "Strike", 130 | ), 131 | ), 132 | editor && 133 | h( 134 | FloatingMenu, 135 | { 136 | className: "floating-menu", 137 | tippyOptions: { 138 | duration: 100, 139 | }, 140 | editor: editor, 141 | }, 142 | h( 143 | "button", 144 | { 145 | onClick: () => 146 | editor 147 | .chain() 148 | .focus() 149 | .toggleHeading({ 150 | level: 1, 151 | }) 152 | .run(), 153 | className: editor.isActive("heading", { 154 | level: 1, 155 | }) 156 | ? "is-active" 157 | : "", 158 | }, 159 | "H1", 160 | ), 161 | h( 162 | "button", 163 | { 164 | onClick: () => 165 | editor 166 | .chain() 167 | .focus() 168 | .toggleHeading({ 169 | level: 2, 170 | }) 171 | .run(), 172 | className: editor.isActive("heading", { 173 | level: 2, 174 | }) 175 | ? "is-active" 176 | : "", 177 | }, 178 | "H2", 179 | ), 180 | h( 181 | "button", 182 | { 183 | onClick: () => editor.chain().focus().toggleBulletList().run(), 184 | className: editor.isActive("bulletList") ? "is-active" : "", 185 | }, 186 | "Bullet list", 187 | ), 188 | ), 189 | ); 190 | 191 | return h( 192 | "div", 193 | { className: "column-half" }, 194 | menu, 195 | h(EditorContent, { 196 | editor: editor, 197 | className: "main-group", 198 | }), 199 | h( 200 | "div", 201 | { 202 | className: "collab-status-group", 203 | "data-state": status === "connected" ? "online" : "offline", 204 | }, 205 | h( 206 | "label", 207 | null, 208 | status === "connected" 209 | ? `${editor.storage.collaborationCursor.users.length} user${editor.storage.collaborationCursor.users.length === 1 ? "" : "s"} online in ${room}` 210 | : "offline", 211 | ), 212 | h( 213 | "button", 214 | { 215 | style: { 216 | "--color": currentUser.color, 217 | }, 218 | onClick: setName, 219 | }, 220 | "\u270E ", 221 | currentUser.name, 222 | ), 223 | ), 224 | ); 225 | }; 226 | 227 | export default Editor; 228 | -------------------------------------------------------------------------------- /playground/public/tiptap/style.css: -------------------------------------------------------------------------------- 1 | /* Source: https://github.com/ueberdosis/tiptap/blob/main/demos/src/Demos/CollaborationSplitPane/React/styles.scss */ 2 | 3 | /* Basic editor styles */ 4 | .tiptap { 5 | /* List styles */ 6 | /* Heading styles */ 7 | /* Code and preformatted text styles */ 8 | /* Highlight specific styles */ 9 | /* Task list specific styles */ 10 | /* Give a remote user a caret */ 11 | /* Render the username above the caret */ 12 | } 13 | .tiptap :first-child { 14 | margin-top: 0; 15 | } 16 | .tiptap ul, 17 | .tiptap ol { 18 | padding: 0 1rem; 19 | margin: 1.25rem 1rem 1.25rem 0.4rem; 20 | } 21 | .tiptap ul li p, 22 | .tiptap ol li p { 23 | margin-top: 0.25em; 24 | margin-bottom: 0.25em; 25 | } 26 | .tiptap h1, 27 | .tiptap h2, 28 | .tiptap h3, 29 | .tiptap h4, 30 | .tiptap h5, 31 | .tiptap h6 { 32 | line-height: 1.1; 33 | margin-top: 2.5rem; 34 | text-wrap: pretty; 35 | } 36 | .tiptap h1, 37 | .tiptap h2 { 38 | margin-top: 3.5rem; 39 | margin-bottom: 1.5rem; 40 | } 41 | .tiptap h1 { 42 | font-size: 1.4rem; 43 | } 44 | .tiptap h2 { 45 | font-size: 1.2rem; 46 | } 47 | .tiptap h3 { 48 | font-size: 1.1rem; 49 | } 50 | .tiptap h4, 51 | .tiptap h5, 52 | .tiptap h6 { 53 | font-size: 1rem; 54 | } 55 | .tiptap code { 56 | background-color: var(--purple-light); 57 | border-radius: 0.4rem; 58 | color: var(--black); 59 | font-size: 0.85rem; 60 | padding: 0.25em 0.3em; 61 | } 62 | .tiptap pre { 63 | background: var(--black); 64 | border-radius: 0.5rem; 65 | color: var(--white); 66 | font-family: "JetBrainsMono", monospace; 67 | margin: 1.5rem 0; 68 | padding: 0.75rem 1rem; 69 | } 70 | .tiptap pre code { 71 | background: none; 72 | color: inherit; 73 | font-size: 0.8rem; 74 | padding: 0; 75 | } 76 | .tiptap blockquote { 77 | border-left: 3px solid var(--gray-3); 78 | margin: 1.5rem 0; 79 | padding-left: 1rem; 80 | } 81 | .tiptap hr { 82 | border: none; 83 | border-top: 1px solid var(--gray-2); 84 | margin: 2rem 0; 85 | } 86 | .tiptap mark { 87 | background-color: #faf594; 88 | border-radius: 0.4rem; 89 | box-decoration-break: clone; 90 | padding: 0.1rem 0.3rem; 91 | } 92 | .tiptap ul[data-type="taskList"] { 93 | list-style: none; 94 | margin-left: 0; 95 | padding: 0; 96 | } 97 | .tiptap ul[data-type="taskList"] li { 98 | align-items: flex-start; 99 | display: flex; 100 | } 101 | .tiptap ul[data-type="taskList"] li > label { 102 | flex: 0 0 auto; 103 | margin-right: 0.5rem; 104 | user-select: none; 105 | } 106 | .tiptap ul[data-type="taskList"] li > div { 107 | flex: 1 1 auto; 108 | } 109 | .tiptap ul[data-type="taskList"] input[type="checkbox"] { 110 | cursor: pointer; 111 | } 112 | .tiptap ul[data-type="taskList"] ul[data-type="taskList"] { 113 | margin: 0; 114 | } 115 | .tiptap p { 116 | word-break: break-all; 117 | } 118 | .tiptap .collaboration-cursor__caret { 119 | border-left: 1px solid #0d0d0d; 120 | border-right: 1px solid #0d0d0d; 121 | margin-left: -1px; 122 | margin-right: -1px; 123 | pointer-events: none; 124 | position: relative; 125 | word-break: normal; 126 | } 127 | .tiptap .collaboration-cursor__label { 128 | border-radius: 3px 3px 3px 0; 129 | color: #0d0d0d; 130 | font-size: 12px; 131 | font-style: normal; 132 | font-weight: 600; 133 | left: -1px; 134 | line-height: normal; 135 | padding: 0.1rem 0.3rem; 136 | position: absolute; 137 | top: -1.4em; 138 | user-select: none; 139 | white-space: nowrap; 140 | } 141 | .col-group { 142 | display: flex; 143 | flex-direction: row; 144 | height: 100vh; 145 | } 146 | @media (max-width: 540px) { 147 | .col-group { 148 | flex-direction: column; 149 | } 150 | } 151 | /* Column-half */ 152 | body { 153 | overflow: hidden; 154 | } 155 | .column-half { 156 | display: flex; 157 | flex-direction: column; 158 | flex: 1; 159 | overflow: auto; 160 | } 161 | .column-half:last-child { 162 | border-left: 1px solid var(--gray-3); 163 | } 164 | @media (max-width: 540px) { 165 | .column-half:last-child { 166 | border-left: none; 167 | border-top: 1px solid var(--gray-3); 168 | } 169 | } 170 | .column-half > .main-group { 171 | flex-grow: 1; 172 | } 173 | /* Collaboration status */ 174 | .collab-status-group { 175 | align-items: center; 176 | background-color: var(--white); 177 | border-top: 1px solid var(--gray-3); 178 | bottom: 0; 179 | color: var(--gray-5); 180 | display: flex; 181 | flex-direction: row; 182 | font-size: 0.75rem; 183 | font-weight: 400; 184 | gap: 1rem; 185 | justify-content: space-between; 186 | padding: 0.375rem 0.5rem 0.375rem 1rem; 187 | position: sticky; 188 | width: 100%; 189 | z-index: 100; 190 | } 191 | .collab-status-group button { 192 | -webkit-box-orient: vertical; 193 | -webkit-line-clamp: 1; 194 | align-self: stretch; 195 | background: none; 196 | display: -webkit-box; 197 | flex-shrink: 1; 198 | font-size: 0.75rem; 199 | max-width: 100%; 200 | padding: 0.25rem 0.375rem; 201 | overflow: hidden; 202 | position: relative; 203 | text-overflow: ellipsis; 204 | white-space: nowrap; 205 | } 206 | .collab-status-group button::before { 207 | background-color: var(--color); 208 | border-radius: 0.375rem; 209 | content: ""; 210 | height: 100%; 211 | left: 0; 212 | opacity: 0.5; 213 | position: absolute; 214 | top: 0; 215 | transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1); 216 | width: 100%; 217 | z-index: -1; 218 | } 219 | .collab-status-group button:hover::before { 220 | opacity: 1; 221 | } 222 | .collab-status-group label { 223 | align-items: center; 224 | display: flex; 225 | flex-direction: row; 226 | flex-shrink: 0; 227 | gap: 0.375rem; 228 | line-height: 1.1; 229 | } 230 | .collab-status-group label::before { 231 | border-radius: 50%; 232 | content: " "; 233 | height: 0.35rem; 234 | width: 0.35rem; 235 | } 236 | .collab-status-group[data-state="online"] label::before { 237 | background-color: var(--green); 238 | } 239 | .collab-status-group[data-state="offline"] label::before { 240 | background-color: var(--red); 241 | } 242 | 243 | /* Bubble menu */ 244 | .bubble-menu { 245 | background-color: var(--white); 246 | border: 1px solid var(--gray-1); 247 | border-radius: 0.7rem; 248 | box-shadow: var(--shadow); 249 | display: flex; 250 | padding: 0.2rem; 251 | } 252 | .bubble-menu button { 253 | background-color: unset; 254 | } 255 | .bubble-menu button:hover { 256 | background-color: var(--gray-3); 257 | } 258 | .bubble-menu button.is-active { 259 | background-color: var(--purple); 260 | } 261 | .bubble-menu button.is-active:hover { 262 | background-color: var(--purple-contrast); 263 | } 264 | /* Floating menu */ 265 | .floating-menu { 266 | display: flex; 267 | background-color: var(--gray-3); 268 | padding: 0.1rem; 269 | border-radius: 0.5rem; 270 | } 271 | .floating-menu button { 272 | background-color: unset; 273 | padding: 0.275rem 0.425rem; 274 | border-radius: 0.3rem; 275 | } 276 | .floating-menu button:hover { 277 | background-color: var(--gray-3); 278 | } 279 | .floating-menu button.is-active { 280 | background-color: var(--white); 281 | color: var(--purple); 282 | } 283 | .floating-menu button.is-active:hover { 284 | color: var(--purple-contrast); 285 | } 286 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import type * as crossws from "crossws"; 2 | import * as Y from "yjs"; 3 | import * as syncProtocol from "y-protocols/sync"; 4 | import * as awarenessProtocol from "y-protocols/awareness"; 5 | import * as encoding from "lib0/encoding"; 6 | import * as decoding from "lib0/decoding"; 7 | 8 | export function createHandler(opts: YCrosswsOptions = {}): YCrosswsHandler { 9 | const yc = new YCrossws(opts); 10 | const hooks: Partial` element. 24 | paragraph: { 25 | attrs: { ychange: { default: null } }, 26 | content: "inline*", 27 | group: "block", 28 | parseDOM: [{ tag: "p" }], 29 | toDOM(node) { 30 | return ["p", calcYchangeDomAttrs(node.attrs), 0]; 31 | }, 32 | }, 33 | 34 | // :: NodeSpec A blockquote (`
`) wrapping one or more blocks. 35 | blockquote: { 36 | attrs: { ychange: { default: null } }, 37 | content: "block+", 38 | group: "block", 39 | defining: true, 40 | parseDOM: [{ tag: "blockquote" }], 41 | toDOM(node) { 42 | return ["blockquote", calcYchangeDomAttrs(node.attrs), 0]; 43 | }, 44 | }, 45 | 46 | // :: NodeSpec A horizontal rule (`
`). 47 | horizontal_rule: { 48 | attrs: { ychange: { default: null } }, 49 | group: "block", 50 | parseDOM: [{ tag: "hr" }], 51 | toDOM(node) { 52 | return ["hr", calcYchangeDomAttrs(node.attrs)]; 53 | }, 54 | }, 55 | 56 | // :: NodeSpec A heading textblock, with a `level` attribute that 57 | // should hold the number 1 to 6. Parsed and serialized as `` to 58 | // `
` elements. 59 | heading: { 60 | attrs: { 61 | level: { default: 1 }, 62 | ychange: { default: null }, 63 | }, 64 | content: "inline*", 65 | group: "block", 66 | defining: true, 67 | parseDOM: [ 68 | { tag: "h1", attrs: { level: 1 } }, 69 | { tag: "h2", attrs: { level: 2 } }, 70 | { tag: "h3", attrs: { level: 3 } }, 71 | { tag: "h4", attrs: { level: 4 } }, 72 | { tag: "h5", attrs: { level: 5 } }, 73 | { tag: "h6", attrs: { level: 6 } }, 74 | ], 75 | toDOM(node) { 76 | return ["h" + node.attrs.level, calcYchangeDomAttrs(node.attrs), 0]; 77 | }, 78 | }, 79 | 80 | // :: NodeSpec A code listing. Disallows marks or non-text inline 81 | // nodes by default. Represented as a `
` element with a 82 | // `` element inside of it. 83 | code_block: { 84 | attrs: { ychange: { default: null } }, 85 | content: "text*", 86 | marks: "", 87 | group: "block", 88 | code: true, 89 | defining: true, 90 | parseDOM: [{ tag: "pre", preserveWhitespace: "full" }], 91 | toDOM(node) { 92 | return ["pre", calcYchangeDomAttrs(node.attrs), ["code", 0]]; 93 | }, 94 | }, 95 | 96 | // :: NodeSpec The text node. 97 | text: { 98 | group: "inline", 99 | }, 100 | 101 | // :: NodeSpec An inline image (``) node. Supports `src`, 102 | // `alt`, and `href` attributes. The latter two default to the empty 103 | // string. 104 | image: { 105 | inline: true, 106 | attrs: { 107 | ychange: { default: null }, 108 | src: {}, 109 | alt: { default: null }, 110 | title: { default: null }, 111 | }, 112 | group: "inline", 113 | draggable: true, 114 | parseDOM: [ 115 | { 116 | tag: "img[src]", 117 | getAttrs(dom) { 118 | return { 119 | src: dom.getAttribute("src"), 120 | title: dom.getAttribute("title"), 121 | alt: dom.getAttribute("alt"), 122 | }; 123 | }, 124 | }, 125 | ], 126 | toDOM(node) { 127 | const domAttrs = { 128 | src: node.attrs.src, 129 | title: node.attrs.title, 130 | alt: node.attrs.alt, 131 | }; 132 | return ["img", calcYchangeDomAttrs(node.attrs, domAttrs)]; 133 | }, 134 | }, 135 | 136 | // :: NodeSpec A hard line break, represented in the DOM as `
`. 137 | hard_break: { 138 | inline: true, 139 | group: "inline", 140 | selectable: false, 141 | parseDOM: [{ tag: "br" }], 142 | toDOM() { 143 | return brDOM; 144 | }, 145 | }, 146 | }; 147 | 148 | const emDOM = ["em", 0]; 149 | const strongDOM = ["strong", 0]; 150 | const codeDOM = ["code", 0]; 151 | 152 | // :: Object [Specs](#model.MarkSpec) for the marks in the schema. 153 | export const marks = { 154 | // :: MarkSpec A link. Has `href` and `title` attributes. `title` 155 | // defaults to the empty string. Rendered and parsed as an `` 156 | // element. 157 | link: { 158 | attrs: { 159 | href: {}, 160 | title: { default: null }, 161 | }, 162 | inclusive: false, 163 | parseDOM: [ 164 | { 165 | tag: "a[href]", 166 | getAttrs(dom) { 167 | return { 168 | href: dom.getAttribute("href"), 169 | title: dom.getAttribute("title"), 170 | }; 171 | }, 172 | }, 173 | ], 174 | toDOM(node) { 175 | return ["a", node.attrs, 0]; 176 | }, 177 | }, 178 | 179 | // :: MarkSpec An emphasis mark. Rendered as an `` element. 180 | // Has parse rules that also match `` and `font-style: italic`. 181 | em: { 182 | parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }], 183 | toDOM() { 184 | return emDOM; 185 | }, 186 | }, 187 | 188 | // :: MarkSpec A strong mark. Rendered as ``, parse rules 189 | // also match `` and `font-weight: bold`. 190 | strong: { 191 | parseDOM: [ 192 | { tag: "strong" }, 193 | // This works around a Google Docs misbehavior where 194 | // pasted content will be inexplicably wrapped in `` 195 | // tags with a font-weight normal. 196 | { 197 | tag: "b", 198 | getAttrs: (node) => node.style.fontWeight !== "normal" && null, 199 | }, 200 | { 201 | style: "font-weight", 202 | getAttrs: (value) => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null, 203 | }, 204 | ], 205 | toDOM() { 206 | return strongDOM; 207 | }, 208 | }, 209 | 210 | // :: MarkSpec Code font mark. Represented as a `` element. 211 | code: { 212 | parseDOM: [{ tag: "code" }], 213 | toDOM() { 214 | return codeDOM; 215 | }, 216 | }, 217 | ychange: { 218 | attrs: { 219 | user: { default: null }, 220 | state: { default: null }, 221 | }, 222 | inclusive: false, 223 | parseDOM: [{ tag: "ychange" }], 224 | toDOM(node) { 225 | return [ 226 | "ychange", 227 | { ychange_user: node.attrs.user, ychange_state: node.attrs.state }, 228 | 0, 229 | ]; 230 | }, 231 | }, 232 | }; 233 | 234 | // :: Schema 235 | // This schema roughly corresponds to the document schema used by 236 | // [CommonMark](http://commonmark.org/), minus the list elements, 237 | // which are defined in the [`prosemirror-schema-list`](#schema-list) 238 | // module. 239 | // 240 | // To reuse elements from this schema, extend or read from its 241 | // `spec.nodes` and `spec.marks` [properties](#model.Schema.spec). 242 | export const schema = new Schema({ nodes, marks }); 243 | -------------------------------------------------------------------------------- /playground/public/tiptap/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |Tiptap + y-crossws 4 | 5 | 6 | 7 | 8 | 9 | 10 |11 | github 12 |13 |14 |23 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /playground/public/prosemirror/style.css: -------------------------------------------------------------------------------- 1 | /* Custom styles for rendering remote cursor locations */ 2 | 3 | placeholder { 4 | display: inline; 5 | border: 1px solid #ccc; 6 | color: #ccc; 7 | } 8 | placeholder:after { 9 | content: "☁"; 10 | font-size: 200%; 11 | line-height: 0.1; 12 | font-weight: bold; 13 | } 14 | .ProseMirror img { 15 | max-width: 100px; 16 | } 17 | 18 | /* this is a rough fix for the first cursor position when the first paragraph is empty */ 19 | .ProseMirror > .ProseMirror-yjs-cursor:first-child { 20 | margin-top: 16px; 21 | } 22 | .ProseMirror p:first-child, 23 | .ProseMirror h1:first-child, 24 | .ProseMirror h2:first-child, 25 | .ProseMirror h3:first-child, 26 | .ProseMirror h4:first-child, 27 | .ProseMirror h5:first-child, 28 | .ProseMirror h6:first-child { 29 | margin-top: 16px; 30 | } 31 | /* This gives the remote user caret. The colors are automatically overwritten*/ 32 | .ProseMirror-yjs-cursor { 33 | position: relative; 34 | margin-left: -1px; 35 | margin-right: -1px; 36 | border-left: 1px solid black; 37 | border-right: 1px solid black; 38 | border-color: orange; 39 | word-break: normal; 40 | pointer-events: none; 41 | } 42 | /* This renders the username above the caret */ 43 | .ProseMirror-yjs-cursor > div { 44 | position: absolute; 45 | top: -1.05em; 46 | left: -1px; 47 | font-size: 13px; 48 | background-color: rgb(250, 129, 0); 49 | font-family: serif; 50 | font-style: normal; 51 | font-weight: normal; 52 | line-height: normal; 53 | user-select: none; 54 | color: white; 55 | padding-left: 2px; 56 | padding-right: 2px; 57 | white-space: nowrap; 58 | } 59 | #y-functions { 60 | position: absolute; 61 | top: 20px; 62 | right: 20px; 63 | } 64 | #y-functions > * { 65 | display: inline-block; 66 | } 67 | 68 | /* Prosemirror's example style */ 69 | 70 | .ProseMirror { 71 | position: relative; 72 | } 73 | 74 | .ProseMirror { 75 | word-wrap: break-word; 76 | white-space: pre-wrap; 77 | -webkit-font-variant-ligatures: none; 78 | font-variant-ligatures: none; 79 | } 80 | 81 | .ProseMirror pre { 82 | white-space: pre-wrap; 83 | } 84 | 85 | .ProseMirror li { 86 | position: relative; 87 | } 88 | 89 | .ProseMirror-hideselection *::selection { 90 | background: transparent; 91 | } 92 | .ProseMirror-hideselection *::-moz-selection { 93 | background: transparent; 94 | } 95 | .ProseMirror-hideselection { 96 | caret-color: transparent; 97 | } 98 | 99 | .ProseMirror-selectednode { 100 | outline: 2px solid #8cf; 101 | } 102 | 103 | /* Make sure li selections wrap around markers */ 104 | 105 | li.ProseMirror-selectednode { 106 | outline: none; 107 | } 108 | 109 | li.ProseMirror-selectednode:after { 110 | content: ""; 111 | position: absolute; 112 | left: -32px; 113 | right: -2px; 114 | top: -2px; 115 | bottom: -2px; 116 | border: 2px solid #8cf; 117 | pointer-events: none; 118 | } 119 | .ProseMirror-textblock-dropdown { 120 | min-width: 3em; 121 | } 122 | 123 | .ProseMirror-menu { 124 | margin: 0 -4px; 125 | line-height: 1; 126 | } 127 | 128 | .ProseMirror-tooltip .ProseMirror-menu { 129 | width: -webkit-fit-content; 130 | width: fit-content; 131 | white-space: pre; 132 | } 133 | 134 | .ProseMirror-menuitem { 135 | margin-right: 3px; 136 | display: inline-block; 137 | } 138 | 139 | .ProseMirror-menuseparator { 140 | border-right: 1px solid #ddd; 141 | margin-right: 3px; 142 | } 143 | 144 | .ProseMirror-menu-dropdown, 145 | .ProseMirror-menu-dropdown-menu { 146 | font-size: 90%; 147 | white-space: nowrap; 148 | } 149 | 150 | .ProseMirror-menu-dropdown { 151 | vertical-align: 1px; 152 | cursor: pointer; 153 | position: relative; 154 | padding-right: 15px; 155 | } 156 | 157 | .ProseMirror-menu-dropdown-wrap { 158 | padding: 1px 0 1px 4px; 159 | display: inline-block; 160 | position: relative; 161 | } 162 | 163 | .ProseMirror-menu-dropdown:after { 164 | content: ""; 165 | border-left: 4px solid transparent; 166 | border-right: 4px solid transparent; 167 | border-top: 4px solid currentColor; 168 | opacity: 0.6; 169 | position: absolute; 170 | right: 4px; 171 | top: calc(50% - 2px); 172 | } 173 | 174 | .ProseMirror-menu-dropdown-menu, 175 | .ProseMirror-menu-submenu { 176 | position: absolute; 177 | background: white; 178 | color: #666; 179 | border: 1px solid #aaa; 180 | padding: 2px; 181 | } 182 | 183 | .ProseMirror-menu-dropdown-menu { 184 | z-index: 15; 185 | min-width: 6em; 186 | } 187 | 188 | .ProseMirror-menu-dropdown-item { 189 | cursor: pointer; 190 | padding: 2px 8px 2px 4px; 191 | } 192 | 193 | .ProseMirror-menu-dropdown-item:hover { 194 | background: #f2f2f2; 195 | } 196 | 197 | .ProseMirror-menu-submenu-wrap { 198 | position: relative; 199 | margin-right: -4px; 200 | } 201 | 202 | .ProseMirror-menu-submenu-label:after { 203 | content: ""; 204 | border-top: 4px solid transparent; 205 | border-bottom: 4px solid transparent; 206 | border-left: 4px solid currentColor; 207 | opacity: 0.6; 208 | position: absolute; 209 | right: 4px; 210 | top: calc(50% - 4px); 211 | } 212 | 213 | .ProseMirror-menu-submenu { 214 | display: none; 215 | min-width: 4em; 216 | left: 100%; 217 | top: -3px; 218 | } 219 | 220 | .ProseMirror-menu-active { 221 | background: #eee; 222 | border-radius: 4px; 223 | } 224 | 225 | .ProseMirror-menu-active { 226 | background: #eee; 227 | border-radius: 4px; 228 | } 229 | 230 | .ProseMirror-menu-disabled { 231 | opacity: 0.3; 232 | } 233 | 234 | .ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, 235 | .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu { 236 | display: block; 237 | } 238 | 239 | .ProseMirror-menubar { 240 | border-top-left-radius: inherit; 241 | border-top-right-radius: inherit; 242 | position: relative; 243 | min-height: 1em; 244 | color: #666; 245 | padding: 1px 6px; 246 | top: 0; 247 | left: 0; 248 | right: 0; 249 | border-bottom: 1px solid silver; 250 | background: white; 251 | z-index: 10; 252 | -moz-box-sizing: border-box; 253 | box-sizing: border-box; 254 | overflow: visible; 255 | } 256 | 257 | .ProseMirror-icon { 258 | display: inline-block; 259 | line-height: 0.8; 260 | vertical-align: -2px; /* Compensate for padding */ 261 | padding: 2px 8px; 262 | cursor: pointer; 263 | } 264 | 265 | .ProseMirror-menu-disabled.ProseMirror-icon { 266 | cursor: default; 267 | } 268 | 269 | .ProseMirror-icon svg { 270 | fill: currentColor; 271 | height: 1em; 272 | } 273 | 274 | .ProseMirror-icon span { 275 | vertical-align: text-top; 276 | } 277 | .ProseMirror-gapcursor { 278 | display: none; 279 | pointer-events: none; 280 | position: absolute; 281 | } 282 | 283 | .ProseMirror-gapcursor:after { 284 | content: ""; 285 | display: block; 286 | position: absolute; 287 | top: -2px; 288 | width: 20px; 289 | border-top: 1px solid black; 290 | animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; 291 | } 292 | 293 | @keyframes ProseMirror-cursor-blink { 294 | to { 295 | visibility: hidden; 296 | } 297 | } 298 | 299 | .ProseMirror-focused .ProseMirror-gapcursor { 300 | display: block; 301 | } 302 | /* Add space around the hr to make clicking it easier */ 303 | 304 | .ProseMirror-example-setup-style hr { 305 | padding: 2px 10px; 306 | border: none; 307 | margin: 1em 0; 308 | } 309 | 310 | .ProseMirror-example-setup-style hr:after { 311 | content: ""; 312 | display: block; 313 | height: 1px; 314 | background-color: silver; 315 | line-height: 2px; 316 | } 317 | 318 | .ProseMirror ul, 319 | .ProseMirror ol { 320 | padding-left: 30px; 321 | } 322 | 323 | .ProseMirror blockquote { 324 | padding-left: 1em; 325 | border-left: 3px solid #eee; 326 | margin-left: 0; 327 | margin-right: 0; 328 | } 329 | 330 | .ProseMirror-example-setup-style img { 331 | cursor: default; 332 | } 333 | 334 | .ProseMirror-prompt { 335 | background: white; 336 | padding: 5px 10px 5px 15px; 337 | border: 1px solid silver; 338 | position: fixed; 339 | border-radius: 3px; 340 | z-index: 11; 341 | box-shadow: -0.5px 2px 5px rgba(0, 0, 0, 0.2); 342 | } 343 | 344 | .ProseMirror-prompt h5 { 345 | margin: 0; 346 | font-weight: normal; 347 | font-size: 100%; 348 | color: #444; 349 | } 350 | 351 | .ProseMirror-prompt input[type="text"], 352 | .ProseMirror-prompt textarea { 353 | background: #eee; 354 | border: none; 355 | outline: none; 356 | } 357 | 358 | .ProseMirror-prompt input[type="text"] { 359 | padding: 0 4px; 360 | } 361 | 362 | .ProseMirror-prompt-close { 363 | position: absolute; 364 | left: 2px; 365 | top: 1px; 366 | color: #666; 367 | border: none; 368 | background: transparent; 369 | padding: 0; 370 | } 371 | 372 | .ProseMirror-prompt-close:after { 373 | content: "✕"; 374 | font-size: 12px; 375 | } 376 | 377 | .ProseMirror-invalid { 378 | background: #ffc; 379 | border: 1px solid #cc7; 380 | border-radius: 4px; 381 | padding: 5px 10px; 382 | position: absolute; 383 | min-width: 10em; 384 | } 385 | 386 | .ProseMirror-prompt-buttons { 387 | margin-top: 5px; 388 | display: none; 389 | } 390 | #editor, 391 | .editor { 392 | background: white; 393 | color: black; 394 | background-clip: padding-box; 395 | border-radius: 4px; 396 | border: 2px solid rgba(0, 0, 0, 0.2); 397 | padding: 5px 0; 398 | margin-bottom: 23px; 399 | } 400 | 401 | .ProseMirror p:first-child, 402 | .ProseMirror h1:first-child, 403 | .ProseMirror h2:first-child, 404 | .ProseMirror h3:first-child, 405 | .ProseMirror h4:first-child, 406 | .ProseMirror h5:first-child, 407 | .ProseMirror h6:first-child { 408 | margin-top: 10px; 409 | } 410 | 411 | .ProseMirror { 412 | padding: 4px 8px 4px 14px; 413 | line-height: 1.2; 414 | outline: none; 415 | } 416 | 417 | .ProseMirror p { 418 | margin-bottom: 1em; 419 | } 420 | -------------------------------------------------------------------------------- /playground/public/tiptap/theme.css: -------------------------------------------------------------------------------- 1 | /* Shamefully stolen from https://embed.tiptap.dev/assets/helper-Bzo17db4.css to avoid tailwind build! */ 2 | 3 | :root { 4 | --white: #fff; 5 | --black: #2e2b29; 6 | --black-contrast: #110f0e; 7 | --gray-1: rgba(61, 37, 20, 0.05); 8 | --gray-2: rgba(61, 37, 20, 0.08); 9 | --gray-3: rgba(61, 37, 20, 0.12); 10 | --gray-4: rgba(53, 38, 28, 0.3); 11 | --gray-5: rgba(28, 25, 23, 0.6); 12 | --green: #22c55e; 13 | --purple: #6a00f5; 14 | --purple-contrast: #5800cc; 15 | --purple-light: rgba(88, 5, 255, 0.05); 16 | --yellow-contrast: #facc15; 17 | --yellow: rgba(250, 204, 21, 0.4); 18 | --yellow-light: #fffae5; 19 | --red: #ff5c33; 20 | --red-light: #ffebe5; 21 | --shadow: 0px 12px 33px 0px rgba(0, 0, 0, 0.06), 22 | 0px 3.618px 9.949px 0px rgba(0, 0, 0, 0.04); 23 | } 24 | *, 25 | *:before, 26 | *:after { 27 | box-sizing: border-box; 28 | } 29 | html { 30 | font-family: 31 | Inter, 32 | ui-sans-serif, 33 | system-ui, 34 | -apple-system, 35 | BlinkMacSystemFont, 36 | Segoe UI, 37 | Roboto, 38 | Helvetica Neue, 39 | Arial, 40 | Noto Sans, 41 | sans-serif, 42 | "Apple Color Emoji", 43 | "Segoe UI Emoji", 44 | Segoe UI Symbol, 45 | "Noto Color Emoji"; 46 | line-height: 1.5; 47 | -moz-osx-font-smoothing: grayscale; 48 | -webkit-font-smoothing: antialiased; 49 | } 50 | body { 51 | min-height: 25rem; 52 | margin: 0; 53 | } 54 | :first-child { 55 | margin-top: 0; 56 | } 57 | .tiptap { 58 | caret-color: var(--purple); 59 | margin: 1.5rem; 60 | } 61 | .tiptap:focus { 62 | outline: none; 63 | } 64 | ::-webkit-scrollbar { 65 | height: 14px; 66 | width: 14px; 67 | } 68 | ::-webkit-scrollbar-track { 69 | background-clip: padding-box; 70 | background-color: transparent; 71 | border: 4px solid transparent; 72 | border-radius: 8px; 73 | } 74 | ::-webkit-scrollbar-thumb { 75 | background-clip: padding-box; 76 | background-color: #0000; 77 | border: 4px solid rgba(0, 0, 0, 0); 78 | border-radius: 8px; 79 | } 80 | :hover::-webkit-scrollbar-thumb { 81 | background-color: #0000001a; 82 | } 83 | ::-webkit-scrollbar-thumb:hover { 84 | background-color: #00000026; 85 | } 86 | ::-webkit-scrollbar-button { 87 | display: none; 88 | height: 0; 89 | width: 0; 90 | } 91 | ::-webkit-scrollbar-corner { 92 | background-color: transparent; 93 | } 94 | button, 95 | input, 96 | select, 97 | textarea { 98 | background: var(--gray-2); 99 | border-radius: 0.5rem; 100 | border: none; 101 | color: var(--black); 102 | font-family: inherit; 103 | font-size: 0.875rem; 104 | font-weight: 500; 105 | line-height: 1.15; 106 | margin: none; 107 | padding: 0.375rem 0.625rem; 108 | transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1); 109 | } 110 | button:hover, 111 | input:hover, 112 | select:hover, 113 | textarea:hover { 114 | background-color: var(--gray-3); 115 | color: var(--black-contrast); 116 | } 117 | button[disabled], 118 | input[disabled], 119 | select[disabled], 120 | textarea[disabled] { 121 | background: var(--gray-1); 122 | color: var(--gray-4); 123 | } 124 | button:checked, 125 | input:checked, 126 | select:checked, 127 | textarea:checked { 128 | accent-color: var(--purple); 129 | } 130 | button.primary, 131 | input.primary, 132 | select.primary, 133 | textarea.primary { 134 | background: var(--black); 135 | color: var(--white); 136 | } 137 | button.primary:hover, 138 | input.primary:hover, 139 | select.primary:hover, 140 | textarea.primary:hover { 141 | background-color: var(--black-contrast); 142 | } 143 | button.primary[disabled], 144 | input.primary[disabled], 145 | select.primary[disabled], 146 | textarea.primary[disabled] { 147 | background: var(--gray-1); 148 | color: var(--gray-4); 149 | } 150 | button.is-active, 151 | input.is-active, 152 | select.is-active, 153 | textarea.is-active { 154 | background: var(--purple); 155 | color: var(--white); 156 | } 157 | button.is-active:hover, 158 | input.is-active:hover, 159 | select.is-active:hover, 160 | textarea.is-active:hover { 161 | background-color: var(--purple-contrast); 162 | color: var(--white); 163 | } 164 | button:not([disabled]), 165 | select:not([disabled]) { 166 | cursor: pointer; 167 | } 168 | input[type="text"], 169 | textarea { 170 | background-color: unset; 171 | border: 1px solid var(--gray-3); 172 | border-radius: 0.5rem; 173 | color: var(--black); 174 | } 175 | input[type="text"]::-moz-placeholder, 176 | textarea::-moz-placeholder { 177 | color: var(--gray-4); 178 | } 179 | input[type="text"]::placeholder, 180 | textarea::placeholder { 181 | color: var(--gray-4); 182 | } 183 | input[type="text"]:hover, 184 | textarea:hover { 185 | background-color: unset; 186 | border-color: var(--gray-4); 187 | } 188 | input[type="text"]:focus-visible, 189 | input[type="text"]:focus, 190 | textarea:focus-visible, 191 | textarea:focus { 192 | border-color: var(--purple); 193 | outline: none; 194 | } 195 | select { 196 | appearance: none; 197 | -webkit-appearance: none; 198 | -moz-appearance: none; 199 | background-image: url('data:image/svg+xml;utf8,'); 200 | background-repeat: no-repeat; 201 | background-position: right 0.1rem center; 202 | background-size: 1.25rem 1.25rem; 203 | padding-right: 1.25rem; 204 | } 205 | select:focus { 206 | outline: 0; 207 | } 208 | form { 209 | align-items: flex-start; 210 | display: flex; 211 | flex-direction: column; 212 | gap: 0.25rem; 213 | } 214 | .hint { 215 | align-items: center; 216 | background-color: var(--yellow-light); 217 | border-radius: 0.5rem; 218 | border: 1px solid var(--gray-2); 219 | display: flex; 220 | flex-direction: row; 221 | font-size: 0.75rem; 222 | gap: 0.25rem; 223 | line-height: 1.15; 224 | padding: 0.3rem 0.5rem; 225 | } 226 | .hint.purple-spinner, 227 | .hint.error { 228 | justify-content: center; 229 | text-align: center; 230 | width: 100%; 231 | } 232 | .hint .badge { 233 | background-color: var(--gray-1); 234 | border: 1px solid var(--gray-3); 235 | border-radius: 2rem; 236 | color: var(--gray-5); 237 | font-size: 0.625rem; 238 | font-weight: 700; 239 | line-height: 1; 240 | padding: 0.25rem 0.5rem; 241 | } 242 | .hint.purple-spinner { 243 | background-color: var(--purple-light); 244 | } 245 | .hint.purple-spinner:after { 246 | content: ""; 247 | background-image: url("data:image/svg+xml;utf8,"); 248 | background-size: cover; 249 | background-repeat: no-repeat; 250 | background-position: center; 251 | height: 1rem; 252 | width: 1rem; 253 | } 254 | .hint.error { 255 | background-color: var(--red-light); 256 | } 257 | .label, 258 | .label-small, 259 | .label-large { 260 | color: var(--black); 261 | font-size: 0.8125rem; 262 | font-weight: 500; 263 | line-height: 1.15; 264 | } 265 | .label-small { 266 | color: var(--gray-5); 267 | font-size: 0.75rem; 268 | font-weight: 400; 269 | } 270 | .label-large { 271 | font-size: 0.875rem; 272 | font-weight: 700; 273 | } 274 | hr { 275 | border: none; 276 | border-top: 1px solid var(--gray-3); 277 | margin: 0; 278 | width: 100%; 279 | } 280 | kbd { 281 | background-color: var(--gray-2); 282 | border: 1px solid var(--gray-2); 283 | border-radius: 0.25rem; 284 | font-size: 0.6rem; 285 | line-height: 1.15; 286 | padding: 0.1rem 0.25rem; 287 | text-transform: uppercase; 288 | } 289 | #app, 290 | .container { 291 | display: flex; 292 | flex-direction: column; 293 | } 294 | .button-group { 295 | display: flex; 296 | flex-wrap: wrap; 297 | gap: 0.25rem; 298 | } 299 | .control-group { 300 | align-items: flex-start; 301 | background-color: var(--white); 302 | display: flex; 303 | flex-direction: column; 304 | gap: 1rem; 305 | padding: 1.5rem; 306 | } 307 | .control-group .sticky { 308 | position: sticky; 309 | top: 0; 310 | } 311 | [data-node-view-wrapper] > .control-group { 312 | padding: 0; 313 | } 314 | .flex-row { 315 | display: flex; 316 | flex-direction: row; 317 | flex-wrap: wrap; 318 | gap: 1rem; 319 | justify-content: space-between; 320 | width: 100%; 321 | } 322 | .switch-group { 323 | align-items: center; 324 | background: var(--gray-2); 325 | border-radius: 0.5rem; 326 | display: flex; 327 | flex-direction: row; 328 | flex-wrap: wrap; 329 | flex: 0 1 auto; 330 | justify-content: flex-start; 331 | padding: 0.125rem; 332 | } 333 | .switch-group label { 334 | align-items: center; 335 | border-radius: 0.375rem; 336 | color: var(--gray-5); 337 | cursor: pointer; 338 | display: flex; 339 | flex-direction: row; 340 | font-size: 0.75rem; 341 | font-weight: 500; 342 | gap: 0.25rem; 343 | line-height: 1.15; 344 | min-height: 1.5rem; 345 | padding: 0 0.375rem; 346 | transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1); 347 | } 348 | .switch-group label:has(input:checked) { 349 | background-color: var(--white); 350 | color: var(--black-contrast); 351 | } 352 | .switch-group label:hover { 353 | color: var(--black); 354 | } 355 | .switch-group label input { 356 | display: none; 357 | margin: unset; 358 | } 359 | .output-group { 360 | background-color: var(--gray-1); 361 | display: flex; 362 | flex-direction: column; 363 | font-family: JetBrainsMono, monospace; 364 | font-size: 0.75rem; 365 | gap: 1rem; 366 | margin-top: 2.5rem; 367 | padding: 1.5rem; 368 | } 369 | .output-group label { 370 | color: var(--black); 371 | font-size: 0.875rem; 372 | font-weight: 700; 373 | line-height: 1.15; 374 | } 375 | -------------------------------------------------------------------------------- /src/provider.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs"; 2 | import * as syncProtocol from "y-protocols/sync"; 3 | import * as authProtocol from "y-protocols/auth"; 4 | import * as awarenessProtocol from "y-protocols/awareness"; 5 | import * as bc from "lib0/broadcastchannel"; 6 | import * as encoding from "lib0/encoding"; 7 | import * as decoding from "lib0/decoding"; 8 | import { ObservableV2 } from "lib0/observable"; 9 | 10 | type Fn = (...args: any[]) => any; 11 | 12 | export interface WebsocketProviderOptions { 13 | /** 14 | * URL parameters 15 | * @default {} 16 | */ 17 | params?: Record15 | 16 |22 |17 | 18 | 19 | Loading demo... 20 |21 |; 18 | /** 19 | * Specify websocket protocols 20 | * @default [] 21 | */ 22 | protocols?: Array ; 23 | /** 24 | * WebSocket polyfill 25 | * @default WebSocket 26 | */ 27 | WebSocketPolyfill?: typeof WebSocket; 28 | /** 29 | * Maximum amount of time to wait before trying to reconnect (we try to reconnect using exponential. 30 | * @default 2500 31 | */ 32 | maxBackoffTime?: number; 33 | /** 34 | * Request server state every `resyncInterval` milliseconds 35 | * @default -1 36 | */ 37 | resyncInterval?: number; 38 | /** 39 | * Whether to connect to other peers or not 40 | * @default true 41 | */ 42 | connect?: boolean; 43 | /** 44 | * Awareness instance 45 | */ 46 | awareness?: awarenessProtocol.Awareness; 47 | /** 48 | * Disable cross-tab BroadcastChannel communication 49 | * @default false 50 | */ 51 | disableBc?: boolean; 52 | } 53 | 54 | // TODO: this should depend on awareness.outdatedTime 55 | const messageReconnectTimeout = 30_000; 56 | 57 | // encoder, decoder, provider, emitSynced, messageType 58 | type MessageHandler = ( 59 | encoder: encoding.Encoder, 60 | decoder: decoding.Decoder, 61 | provider: WebsocketProvider, 62 | emitSynced: boolean, 63 | messageType: number, 64 | ) => void; 65 | 66 | const messageSync = 0; 67 | const messageAwareness = 1; 68 | // const messageAuth = 2; 69 | const messageQueryAwareness = 3; 70 | 71 | const messageHandlers: [ 72 | MessageHandler, 73 | MessageHandler, 74 | MessageHandler, 75 | MessageHandler, 76 | ] = [ 77 | // 0: messageSync 78 | (encoder, decoder, provider, emitSynced, _messageType) => { 79 | encoding.writeVarUint(encoder, messageSync); 80 | const syncMessageType = syncProtocol.readSyncMessage( 81 | decoder, 82 | encoder, 83 | provider.doc, 84 | provider, 85 | ); 86 | if ( 87 | emitSynced && 88 | syncMessageType === syncProtocol.messageYjsSyncStep2 && 89 | !provider.synced 90 | ) { 91 | provider.synced = true; 92 | } 93 | }, 94 | // 1: messageAwareness 95 | (encoder, decoder, provider, _emitSynced, _messageType) => { 96 | awarenessProtocol.applyAwarenessUpdate( 97 | provider.awareness, 98 | decoding.readVarUint8Array(decoder), 99 | provider, 100 | ); 101 | }, 102 | // 2: messageAuth 103 | (_encoder, decoder, provider, _emitSynced, _messageType) => { 104 | authProtocol.readAuthMessage(decoder, provider.doc, (_ydoc, reason) => { 105 | console.warn( 106 | `[y-crossws-provider] Permission denied to access ${provider.url}.\n${reason}`, 107 | ); 108 | }); 109 | }, 110 | // 3: messageQueryAwareness 111 | (encoder, _decoder, provider, _emitSynced, _messageType) => { 112 | encoding.writeVarUint(encoder, messageAwareness); 113 | encoding.writeVarUint8Array( 114 | encoder, 115 | awarenessProtocol.encodeAwarenessUpdate(provider.awareness, [ 116 | ...provider.awareness.getStates().keys(), 117 | ]), 118 | ); 119 | }, 120 | ]; 121 | 122 | /** 123 | * Websocket Provider for Yjs. Creates a websocket connection to sync the shared document. 124 | * The document name is attached to the provided url. I.e. the following example 125 | * creates a websocket connection to http://localhost:1234/my-document-name 126 | * 127 | * @example 128 | * import * as Y from 'yjs' 129 | * import { WebsocketProvider } from 'y-crossws' 130 | * const doc = new Y.Doc() 131 | * const provider = new WebsocketProvider('http://localhost:1234', 'my-document-name', doc) 132 | */ 133 | export class WebsocketProvider extends ObservableV2 { 134 | serverUrl: string; 135 | bcChannel: string; 136 | maxBackoffTime: number; 137 | params: Record ; 138 | protocols: string[]; 139 | roomname: string; 140 | disableBc: boolean; 141 | shouldConnect: boolean; 142 | 143 | doc: Y.Doc; 144 | awareness: awarenessProtocol.Awareness; 145 | 146 | ws?: WebSocket; 147 | wsconnected: boolean = false; 148 | wsconnecting: boolean = false; 149 | wsUnsuccessfulReconnects: number = 0; 150 | wsLastMessageReceived: number = 0; 151 | bcconnected: boolean = false; 152 | messageHandlers: Array ; 153 | 154 | _WS: typeof WebSocket; 155 | _synced: boolean = false; 156 | _resyncInterval: number | ReturnType = 0; 157 | _checkInterval?: ReturnType ; 158 | _bcSubscriber: (data: ArrayBuffer, origin: any) => void; 159 | _updateHandler: (update: Uint8Array, origin: any) => void; 160 | _awarenessUpdateHandler: (update: any, origin: any) => void; 161 | _exitHandler: () => void; 162 | 163 | constructor( 164 | serverUrl: string, 165 | roomname: string, 166 | doc: Y.Doc, 167 | { 168 | connect = true, 169 | awareness = new awarenessProtocol.Awareness(doc), 170 | params = {}, 171 | protocols = [], 172 | WebSocketPolyfill = WebSocket, 173 | resyncInterval = -1, 174 | maxBackoffTime = 2500, 175 | disableBc = false, 176 | }: WebsocketProviderOptions = {}, 177 | ) { 178 | super(); 179 | 180 | this.serverUrl = serverUrl.replace(/\/$/, ""); 181 | this.bcChannel = this.serverUrl + "/" + roomname; 182 | this.maxBackoffTime = maxBackoffTime; 183 | this.params = params; 184 | this.protocols = protocols; 185 | this.roomname = roomname; 186 | this.doc = doc; 187 | this._WS = WebSocketPolyfill; 188 | this.awareness = awareness; 189 | this.disableBc = disableBc; 190 | this.shouldConnect = connect; 191 | this.messageHandlers = [...messageHandlers]; 192 | 193 | if (resyncInterval > 0) { 194 | this._resyncInterval = setInterval(() => { 195 | if (this.ws?.readyState === WebSocket.OPEN) { 196 | // Resend sync step 1 197 | const encoder = encoding.createEncoder(); 198 | encoding.writeVarUint(encoder, messageSync); 199 | syncProtocol.writeSyncStep1(encoder, doc); 200 | this.ws.send(encoding.toUint8Array(encoder)); 201 | } 202 | }, resyncInterval); 203 | } 204 | 205 | this._bcSubscriber = (data, origin) => { 206 | if (origin !== this) { 207 | const encoder = readMessage(this, new Uint8Array(data), false); 208 | if (encoding.length(encoder) > 1) { 209 | bc.publish(this.bcChannel, encoding.toUint8Array(encoder), this); 210 | } 211 | } 212 | }; 213 | 214 | this._updateHandler = (update, origin) => { 215 | if (origin !== this) { 216 | const encoder = encoding.createEncoder(); 217 | encoding.writeVarUint(encoder, messageSync); 218 | syncProtocol.writeUpdate(encoder, update); 219 | broadcastMessage(this, encoding.toUint8Array(encoder)); 220 | } 221 | }; 222 | 223 | this.doc.on("update", this._updateHandler); 224 | 225 | this._awarenessUpdateHandler = ({ added, updated, removed }, _origin) => { 226 | // eslint-disable-next-line unicorn/prefer-spread 227 | const changedClients = added.concat(updated).concat(removed); 228 | const encoder = encoding.createEncoder(); 229 | encoding.writeVarUint(encoder, messageAwareness); 230 | encoding.writeVarUint8Array( 231 | encoder, 232 | awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients), 233 | ); 234 | broadcastMessage(this, encoding.toUint8Array(encoder)); 235 | }; 236 | 237 | this._exitHandler = () => { 238 | awarenessProtocol.removeAwarenessStates( 239 | this.awareness, 240 | [doc.clientID], 241 | "app closed", 242 | ); 243 | }; 244 | 245 | if (typeof globalThis.process?.on === "function") { 246 | globalThis.process.on("exit", this._exitHandler); 247 | } 248 | 249 | awareness.on("update", this._awarenessUpdateHandler); 250 | 251 | this._checkInterval = setInterval(() => { 252 | if ( 253 | this.wsconnected && 254 | messageReconnectTimeout < Date.now() - this.wsLastMessageReceived 255 | ) { 256 | // no message received in a long time - not even your own awareness 257 | // updates (which are updated every 15 seconds) 258 | this.ws?.close(); 259 | } 260 | }, messageReconnectTimeout / 10); 261 | 262 | if (connect) { 263 | this.connect(); 264 | } 265 | } 266 | 267 | get url() { 268 | const encodedParams = 269 | Object.keys(this.params).length > 0 270 | ? `?${new URLSearchParams(this.params).toString()}` 271 | : ""; 272 | return this.serverUrl + "/" + this.roomname + encodedParams; 273 | } 274 | 275 | get synced(): boolean { 276 | return this._synced; 277 | } 278 | 279 | set synced(state: boolean) { 280 | if (this._synced !== state) { 281 | this._synced = state; 282 | this.emit("synced", [state]); 283 | this.emit("sync", [state]); 284 | } 285 | } 286 | 287 | destroy() { 288 | if (this._resyncInterval !== 0) { 289 | clearInterval(this._resyncInterval); 290 | } 291 | clearInterval(this._checkInterval); 292 | this.disconnect(); 293 | if (typeof globalThis.process?.on === "function") { 294 | process.off("exit", this._exitHandler); 295 | } 296 | this.awareness.off("update", this._awarenessUpdateHandler); 297 | this.doc.off("update", this._updateHandler); 298 | super.destroy(); 299 | } 300 | 301 | connectBc() { 302 | if (this.disableBc) { 303 | return; 304 | } 305 | if (!this.bcconnected) { 306 | bc.subscribe(this.bcChannel, this._bcSubscriber); 307 | this.bcconnected = true; 308 | } 309 | // Send sync step1 to bc 310 | // Write sync step 1 311 | const encoderSync = encoding.createEncoder(); 312 | encoding.writeVarUint(encoderSync, messageSync); 313 | syncProtocol.writeSyncStep1(encoderSync, this.doc); 314 | bc.publish(this.bcChannel, encoding.toUint8Array(encoderSync), this); 315 | // Broadcast local state 316 | const encoderState = encoding.createEncoder(); 317 | encoding.writeVarUint(encoderState, messageSync); 318 | syncProtocol.writeSyncStep2(encoderState, this.doc); 319 | bc.publish(this.bcChannel, encoding.toUint8Array(encoderState), this); 320 | // Write queryAwareness 321 | const encoderAwarenessQuery = encoding.createEncoder(); 322 | encoding.writeVarUint(encoderAwarenessQuery, messageQueryAwareness); 323 | bc.publish( 324 | this.bcChannel, 325 | encoding.toUint8Array(encoderAwarenessQuery), 326 | this, 327 | ); 328 | // Broadcast local awareness state 329 | const encoderAwarenessState = encoding.createEncoder(); 330 | encoding.writeVarUint(encoderAwarenessState, messageAwareness); 331 | encoding.writeVarUint8Array( 332 | encoderAwarenessState, 333 | awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ 334 | this.doc.clientID, 335 | ]), 336 | ); 337 | bc.publish( 338 | this.bcChannel, 339 | encoding.toUint8Array(encoderAwarenessState), 340 | this, 341 | ); 342 | } 343 | 344 | disconnectBc() { 345 | // Broadcast message with local awareness state set to null (indicating disconnect) 346 | const encoder = encoding.createEncoder(); 347 | encoding.writeVarUint(encoder, messageAwareness); 348 | encoding.writeVarUint8Array( 349 | encoder, 350 | awarenessProtocol.encodeAwarenessUpdate( 351 | this.awareness, 352 | [this.doc.clientID], 353 | new Map(), 354 | ), 355 | ); 356 | broadcastMessage(this, encoding.toUint8Array(encoder)); 357 | if (this.bcconnected) { 358 | bc.unsubscribe(this.bcChannel, this._bcSubscriber); 359 | this.bcconnected = false; 360 | } 361 | } 362 | 363 | disconnect() { 364 | this.shouldConnect = false; 365 | this.disconnectBc(); 366 | if (this.ws !== null) { 367 | this.ws?.close(); 368 | } 369 | } 370 | 371 | connect() { 372 | this.shouldConnect = true; 373 | if (!this.wsconnected && this.ws === null) { 374 | setupWS(this); 375 | this.connectBc(); 376 | } 377 | } 378 | } 379 | 380 | function setupWS(provider: WebsocketProvider) { 381 | if (provider.shouldConnect && provider.ws === null) { 382 | const _WebSocket = provider._WS || globalThis.WebSocket; 383 | const websocket = new _WebSocket(provider.url, provider.protocols); 384 | websocket.binaryType = "arraybuffer"; 385 | 386 | provider.ws = websocket; 387 | provider.wsconnecting = true; 388 | provider.wsconnected = false; 389 | provider.synced = false; 390 | 391 | websocket.addEventListener("message", (event) => { 392 | provider.wsLastMessageReceived = Date.now(); 393 | const encoder = readMessage(provider, new Uint8Array(event.data), true); 394 | if (encoding.length(encoder) > 1) { 395 | websocket.send(encoding.toUint8Array(encoder)); 396 | } 397 | }); 398 | 399 | websocket.addEventListener("error", (event) => { 400 | provider.emit("connection-error", [event, provider]); 401 | }); 402 | 403 | websocket.addEventListener("close", (event) => { 404 | provider.emit("connection-close", [event, provider]); 405 | provider.ws = undefined; 406 | provider.wsconnecting = false; 407 | if (provider.wsconnected) { 408 | provider.wsconnected = false; 409 | provider.synced = false; 410 | // Update awareness (all users except local left) 411 | awarenessProtocol.removeAwarenessStates( 412 | provider.awareness, 413 | [...provider.awareness.getStates().keys()].filter( 414 | (client) => client !== provider.doc.clientID, 415 | ), 416 | provider, 417 | ); 418 | provider.emit("status", [{ status: "disconnected" }]); 419 | } else { 420 | provider.wsUnsuccessfulReconnects++; 421 | } 422 | // Start with no reconnect timeout and increase timeout by 423 | // using exponential backoff starting with 100ms 424 | setTimeout( 425 | setupWS, 426 | Math.min( 427 | Math.pow(2, provider.wsUnsuccessfulReconnects) * 100, 428 | provider.maxBackoffTime, 429 | ), 430 | provider, 431 | ); 432 | }); 433 | 434 | websocket.addEventListener("open", () => { 435 | provider.wsLastMessageReceived = Date.now(); 436 | provider.wsconnecting = false; 437 | provider.wsconnected = true; 438 | provider.wsUnsuccessfulReconnects = 0; 439 | provider.emit("status", [{ status: "connected" }]); 440 | // Always send sync step 1 when connected 441 | const encoder = encoding.createEncoder(); 442 | encoding.writeVarUint(encoder, messageSync); 443 | syncProtocol.writeSyncStep1(encoder, provider.doc); 444 | websocket.send(encoding.toUint8Array(encoder)); 445 | // Broadcast local awareness state 446 | if (provider.awareness.getLocalState() !== null) { 447 | const encoderAwarenessState = encoding.createEncoder(); 448 | encoding.writeVarUint(encoderAwarenessState, messageAwareness); 449 | encoding.writeVarUint8Array( 450 | encoderAwarenessState, 451 | awarenessProtocol.encodeAwarenessUpdate(provider.awareness, [ 452 | provider.doc.clientID, 453 | ]), 454 | ); 455 | websocket.send(encoding.toUint8Array(encoderAwarenessState)); 456 | } 457 | }); 458 | 459 | provider.emit("status", [{ status: "connecting" }]); 460 | } 461 | } 462 | 463 | function broadcastMessage(provider: WebsocketProvider, buf: ArrayBuffer) { 464 | if (!provider.wsconnected) { 465 | return; 466 | } 467 | const ws = provider.ws; 468 | if (ws && ws?.readyState === ws.OPEN) { 469 | ws.send(buf); 470 | } 471 | bc.publish(provider.bcChannel, buf, provider); 472 | } 473 | 474 | function readMessage( 475 | provider: WebsocketProvider, 476 | buf: Uint8Array, 477 | emitSynced: boolean, 478 | ): encoding.Encoder { 479 | const decoder = decoding.createDecoder(buf); 480 | const encoder = encoding.createEncoder(); 481 | const messageType = decoding.readVarUint(decoder); 482 | const messageHandler = provider.messageHandlers[messageType]; 483 | if (messageHandler) { 484 | messageHandler(encoder, decoder, provider, emitSynced, messageType); 485 | } else { 486 | console.error("[y-crossws-provider] Unable to compute message"); 487 | } 488 | return encoder; 489 | } 490 | --------------------------------------------------------------------------------