Hocuspocus with Supabase
TLDR
With about a hundred lines of code, you can build a collaborative editor backend with role based access control, using Hocuspocus and Supabase.
Introduction
The focus of our company is building collaborative rich text editors and collaborative user interfaces for web or mobile applications. In this article we will focus on collaborative text editing, but the same principles apply to any collaborative UIs.
Synchronisation is a hard problem.
When building collaborative stuff the common challenge is to synchronize the data between the clients. When multiple users edit a single document a lot of things can go wrong. User experience has to be seamless even for large documents, even when a lot of people edit large documents, even when groups of people edit large documents concurrently. Not all users have all kind of access in a document. Maybe a user only has read and commenting access. Anyway you get the idea...
Our history with collaborative UIs
We used ProseMirror for a long time. It has a good support for collaborative editing out of the box, but it is not complete. prosemirror-colab will resolve conflicts for you, but you have to implement the backend yourself. You can do everything with websockets manually, just tell a server when a user edits and synchronize the docs via websockets. That means a lot of code to write, to maintain and to avoid pitfalls. Who wants to do that? Before Hocuspocus we relied on realtime solutions like Firebase live queries and GraphQL subscriptions so that we don't have to deal with websockets, but still it's a lot of work. And this is where Hocuspocus comes in the picture, it's an out-of-the-box backend for real time collaboration.
We tried it on real projects
Recently we started using Hocuspocus for a couple of new projects. (By the way you can use Hocuspocus with any database).
Hocuspocus?
Hocuspocus abstracts away a great deal of the hassle of building collaborative stuff. As we will show you can bootstrap a collaborative editor relatively quickly.
What is Hocuspocus exactly?
Hocuspocus is a standalone server library for synchronizing Ydocs from Y.js across multiple clients. It was developed by the amazing engineers at TipTap. If you want collaborative editing in your application, you will probably come across Yjs and CRDTs. It is a great library, but it is not a complete solution, you will need something to synchronize the data. The Hocuspocus Server is a WebSocket backend, which has everything to get started quickly, to integrate Y.js in your existing infrastructure and to scale to a million users.
Supabase
The focus of this article is not Supabase, but we tend to use it more and more recently. Especially for MVPs and small projects. Supabase is an open source Firebase alternative. It is a hosted Postgres database with a realtime API and a dashboard. It has authentication and RLS policies integrated to it. It is a great tool to get started quickly, and it is free for small projects, but the paid version has almost the same prices as AWS.
Now the hands-on part
Authentication and role handling
The interesting part starts when you want to integrate authentication and role handling from Supabase into Hocuspocus. The other interesting part when some features are not properly documented, so you have to dig into the source code to find out how to use them. For example from the server side client login with the Supabase JWT user tokens init looks like this:
1const getSupabaseClient = async <DB>(refreshToken: string, accessToken: string): Promise<SupabaseClient<DB>> => { 2 const supabase = createClient<DB>(process.env.NEXT_PUBLIC_SUPABASE_URL ?? "", process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "", 3 { 4 auth: { 5 autoRefreshToken: false, // All my Supabase access is from server, so no need to refresh the token 6 detectSessionInUrl: false, // We are not using OAuth, so we don't need this. Also, we are manually "detecting" the session in the server-side code 7 persistSession: false // All our access is from server, so no need to persist the session to browser's local storage 8 } 9 }); 10 const res = await supabase.auth.setSession({ 11 refresh_token: refreshToken, 12 access_token: accessToken 13 }); 14 if (res.error) { 15 throw Error("Invalid token"); 16 } 17 return supabase; 18}
The build in auth can add the validated tokens to the context.
1const auth = async (data: onAuthenticatePayload) => { 2 const { token } = data; 3 4 try { 5 const { refreshToken, accessToken } = JSON.parse(token); 6 const supabase = await getSupabaseClient<SDB>(refreshToken, accessToken); 7 8 const userResp = await supabase.auth.getUser(); 9 if(userResp.error) { 10 throw Error("Invalid token"); 11 } 12 const user = userResp.data.user 13 14 // You can set contextual data to use it in other hooks 15 return { 16 user, 17 refreshToken, 18 accessToken 19 }; 20 } catch (error) { 21 throw Error("Invalid token"); 22 } 23}
If we have the context set, you can use the Hocuspocus Database extension for read, but if you want to force-write on some user actions, you can't really use the onStoreDocument. If you are okay with a non-recent version in your database (most of the cases this should be ok), you can write the store hook to save too.
If you have a documents table in your database, and some "readonly" role in a roles table, you can use the following code which will cover the read-only, and preloading functionality.
1const dbConfig: DatabaseConfiguration = { 2 fetch: async (data: fetchPayload): Promise<Uint8Array | null> => { 3 const supabase = await getSupabaseClient<SDB>(data.context.refreshToken, data.context.accessToken); 4 5 const doc = await supabase 6 .from("documents") 7 .select() 8 .eq("id", data.documentName) 9 .single(); 10 if (doc.error) { 11 console.log(data.documentName); 12 console.log(doc.error); 13 throw Error("Document not found"); 14 } 15 const role = await supabase 16 .from("roles") 17 .select() 18 .eq("org_id", doc.data.owner_id) 19 .eq("user_id", data.context.user.id) 20 .single(); 21 if (role.error) { 22 throw Error("Role not found"); // this should never happen bcs of RLS 23 } 24 if (role.data.readonly) { 25 data.connection.readOnly = true; // this sets the state to readonly 26 } 27 return new Uint8Array(Buffer.from(doc.data.data, "base64")); 28 }, 29 store: (): void => { 30 // we do nothing here, the user will store the data on click events 31 } 32};
After these relatively straightforward steps, you can start the Hocuspocus server with Supabase as a backend.
1const startServer = async () => { 2 const PORT = process.env.PORT ?? "8080"; 3 // Configure hocuspocus 4 const server = Server.configure({ 5 port: PORT, 6 onAuthenticate: auth, 7 extensions: [ 8 new Database(dbConfig) 9 ] 10 }); 11 12 const { app } = expressWebsockets(express()); 13 14 app.get("/", (request, response) => { 15 response.send("Hello World!"); 16 }); 17 18 app.ws("/collaboration/:document", (websocket, request) => { 19 server.handleConnection(websocket, request, request.params.document); 20 }); 21 22 app.listen(PORT, () => { 23 console.log(`App is listening on ${PORT}`); 24 }); 25 26}; 27 28startServer();
In your frontend code you can use the Hocuspocus provider library to connect to the server (with @supabase/auth-helpers-react).
1const supabaseSession = useSession(); // from supabase 2 3const wsProvider = useMemo(() => { 4 if (ydoc && supabaseSession && process.env.NEXT_PUBLIC_HOCUSPOCUS_SERVICE_URI) { 5 const token = JSON.stringify({ 6 refreshToken: supabaseSession.refresh_token, 7 accessToken: supabaseSession.access_token, 8 }) 9 return new HocuspocusProvider({ 10 url: `ws://undefined/collaboration`, 11 name: uid ?? "test", 12 document: ydoc, 13 token, 14 }); 15 } 16 }, [supabaseSession, uid, ydoc]);
If you need a quick snippet about our RLS policies, here it is:
1create policy "Only do things based on owner_id" 2on "public"."documents" 3as permissive 4for all 5to authenticated 6using ((EXISTS ( SELECT 1 7 FROM "roles" 8 WHERE ((documents.owner_id = "roles".org_id))))); 9 10create policy "Only do things based on user_id" 11on "public"."roles" 12as permissive 13for all 14to authenticated 15using ((user_id = auth.uid()));