Skip to content

Commit

Permalink
Yjs Electric provider example (#1508)
Browse files Browse the repository at this point in the history
A codemirror example with Yjs using Electric for sync

Contains an Y-Electric connection provider that we will extract and ship as a separate package.
  • Loading branch information
balegas authored Dec 9, 2024
1 parent 9886b08 commit 35006dd
Show file tree
Hide file tree
Showing 25 changed files with 1,544 additions and 6 deletions.
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

# sst
.sst
2 changes: 1 addition & 1 deletion examples/nextjs-example/app/shape-proxy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ export async function GET(request: Request) {
})
}
return resp
}
}
2 changes: 2 additions & 0 deletions examples/yjs/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/build/**
sst.config.ts
44 changes: 44 additions & 0 deletions examples/yjs/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
`eslint:recommended`,
`plugin:@typescript-eslint/recommended`,
`plugin:prettier/recommended`,
'plugin:@next/next/recommended',
],
parserOptions: {
ecmaVersion: 2022,
requireConfigFile: false,
sourceType: `module`,
ecmaFeatures: {
jsx: true,
},
},
parser: `@typescript-eslint/parser`,
plugins: [`prettier`],
rules: {
quotes: [`error`, `backtick`],
"no-unused-vars": `off`,
"@typescript-eslint/no-unused-vars": [
`error`,
{
argsIgnorePattern: `^_`,
varsIgnorePattern: `^_`,
caughtErrorsIgnorePattern: `^_`,
},
],
"@next/next/no-img-element": "off"
},
ignorePatterns: [
`**/node_modules/**`,
`**/dist/**`,
`tsup.config.ts`,
`vitest.config.ts`,
`.eslintrc.js`,
`**/*.css`,
],
};
10 changes: 10 additions & 0 deletions examples/yjs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
dist
.env.local

# Turborepo
.turbo

# next.js
/.next/
/out/
next-env.d.ts
5 changes: 5 additions & 0 deletions examples/yjs/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"trailingComma": "es5",
"semi": false,
"tabWidth": 2
}
31 changes: 31 additions & 0 deletions examples/yjs/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
FROM node:lts-alpine AS base

# Stage 1: Install dependencies
FROM base AS deps
WORKDIR /app

RUN npm install -g pnpm

COPY pnpm-*.yaml ./
COPY package.json ./
COPY tsconfig.build.json ./
COPY packages/typescript-client packages/typescript-client/
COPY packages/react-hooks packages/react-hooks/
COPY examples/yjs-provider/ examples/yjs-provider/

# Install dependencies
RUN pnpm install --frozen-lockfile
RUN pnpm run -r build


# Need to make production image more clean
FROM node:lts-alpine AS prod
WORKDIR /app

ENV NODE_ENV=production
COPY --from=deps /app/ ./

WORKDIR /app/examples/yjs-provider/

EXPOSE 3000
CMD ["npm", "run", "start"]
28 changes: 28 additions & 0 deletions examples/yjs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Yjs Electric provider

This example showcases a multiplayer [Codemirror](https://codemirror.net/) editor with [YJS](https://github.com/yjs/yjs) and [ElectricSQL](https://electric-sql.com/). All data is synchronized through [Postgres](https://www.postgresql.org/), eliminating the need for additional real-time infrastructure.

Y-Electric is a [YJS connection provider](https://docs.yjs.dev/ecosystem/connection-provider) that comes with offline support, integrates with [database providers](https://docs.yjs.dev/ecosystem/database-provider) and also handles [Presence/Awareness](https://docs.yjs.dev/api/about-awareness) data. It works with the entire YJS ecosystem and with you existing apps too!

> We're releasing The Y-Electric backend as a package soon!
## How to run

Make sure you've installed all dependencies for the monorepo and built the packages (from the monorepo root directory):

```shell
pnpm install
pnpm run -r build
```

Start the docker containers (in this directory):

```shell
pnpm backend:up
```

Start the dev server:

```shell
pnpm dev
```
73 changes: 73 additions & 0 deletions examples/yjs/app/api/operation/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Pool } from "pg"
import { NextResponse } from "next/server"
import { neon } from "@neondatabase/serverless"

// hybrid implementation for connection pool and serverless with neon

const connectionString =
process.env.NEON_DATABASE_URL ||
process.env.DATABASE_URL ||
`postgresql://postgres:password@localhost:54321/electric`

const sql = process.env.NEON_DATABASE_URL ? neon(connectionString) : undefined

const pool = !process.env.NEON_DATABASE_URL
? new Pool({ connectionString })
: undefined

export async function POST(request: Request) {
try {
const { room, op, clientId } = await getRequestParams(request)
if (!clientId) {
await saveOperation(room, op)
} else {
await saveAwarenessOperation(room, op, clientId)
}
return NextResponse.json({})
} catch (e) {
const resp = e instanceof Error ? e.message : e
return NextResponse.json(resp, { status: 400 })
}
}

async function saveOperation(room: string, op: string) {
const q = `INSERT INTO ydoc_operations (room, op) VALUES ($1, decode($2, 'base64'))`
const params = [room, op]
await runQuery(q, params)
}

async function saveAwarenessOperation(
room: string,
op: string,
clientId: string
) {
const q = `INSERT INTO ydoc_awareness (room, clientId, op) VALUES ($1, $2, decode($3, 'base64'))
ON CONFLICT (clientId, room)
DO UPDATE SET op = decode($3, 'base64'), updated = now()`
const params = [room, clientId, op]
await runQuery(q, params)
}

async function getRequestParams(
request: Request
): Promise<{ room: string; op: string; clientId?: string }> {
const { room, op, clientId } = await request.json()
if (!room) {
throw new Error(`'room' is required`)
}
if (!op) {
throw new Error(`'op' is required`)
}

return { room, op, clientId }
}

async function runQuery(q: string, params: string[]) {
if (pool) {
await pool.query(q, params)
} else if (sql) {
await sql(q, params)
} else {
throw new Error(`No database driver provided`)
}
}
115 changes: 115 additions & 0 deletions examples/yjs/app/electric-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client"

import { useEffect, useRef, useState } from "react"

import * as Y from "yjs"
import { yCollab, yUndoManagerKeymap } from "y-codemirror.next"
import { ElectricProvider } from "./y-electric"
import { Awareness } from "y-protocols/awareness"

import { EditorState } from "@codemirror/state"
import { EditorView, basicSetup } from "codemirror"
import { keymap } from "@codemirror/view"
import { javascript } from "@codemirror/lang-javascript"

import * as random from "lib0/random"
import { IndexeddbPersistence } from "y-indexeddb"

const room = `electric-demo`

const usercolors = [
{ color: `#30bced`, light: `#30bced33` },
{ color: `#6eeb83`, light: `#6eeb8333` },
{ color: `#ffbc42`, light: `#ffbc4233` },
{ color: `#ecd444`, light: `#ecd44433` },
{ color: `#ee6352`, light: `#ee635233` },
{ color: `#9ac2c9`, light: `#9ac2c933` },
]

const userColor = usercolors[random.uint32() % usercolors.length]
const ydoc = new Y.Doc()

const isServer = typeof window === `undefined`

const awareness = !isServer ? new Awareness(ydoc) : undefined
awareness?.setLocalStateField(`user`, {
name: userColor.color,
color: userColor.color,
colorLight: userColor.light,
})

const network = !isServer
? new ElectricProvider(
new URL(`/shape-proxy`, window?.location.origin).href,
room,
ydoc,
{
connect: true,
awareness,
persistence: new IndexeddbPersistence(room, ydoc),
}
)
: undefined

export default function ElectricEditor() {
const editor = useRef(null)

const [connectivityStatus, setConnectivityStatus] = useState<
`connected` | `disconnected`
>(`connected`)

const toggle = () => {
if (!network) {
return
}
const toggleStatus =
connectivityStatus === `connected` ? `disconnected` : `connected`
setConnectivityStatus(toggleStatus)
toggleStatus === `connected` ? network.connect() : network.disconnect()
}

useEffect(() => {
if (typeof window === `undefined`) {
return
}

const ytext = ydoc.getText(room)

const state = EditorState.create({
doc: ytext.toString(),
extensions: [
keymap.of([...yUndoManagerKeymap]),
basicSetup,
javascript(),
EditorView.lineWrapping,
yCollab(ytext, awareness),
],
})

const view = new EditorView({ state, parent: editor.current ?? undefined })

return () => view.destroy()
})

return (
<div>
<form action={async () => toggle()}>
<button type="submit" className="button" name="intent" value="add">
{connectivityStatus}
</button>
</form>
<p>
This is a demo of <a href="https://github.com/yjs/yjs">Yjs</a> using
{` `}
{` `}
<a href="https://github.com/electric-sql/electric">Electric</a> for
syncing.
</p>
<p>
The content of this editor is shared with every client that visits this
domain.
</p>
<div ref={editor}></div>
</div>
)
}
16 changes: 16 additions & 0 deletions examples/yjs/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const metadata = {
title: `Yjs <> Electric`,
description: `Yjs synching with Electric`,
}

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
8 changes: 8 additions & 0 deletions examples/yjs/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"use server"

import React from "react"
import ElectricEditor from "./electric-editor"

const Page = async () => <ElectricEditor />

export default Page
Loading

0 comments on commit 35006dd

Please sign in to comment.